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, LLMProvider, Result, Tool};
8use ai_agents_hitl::{ApprovalHandler, HITLEngine, RejectAllHandler};
9use ai_agents_hooks::AgentHooks;
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_process::ProcessProcessor;
16use ai_agents_reasoning::{ReasoningConfig, ReflectionConfig};
17use ai_agents_recovery::{MessageFilter, RecoveryManager};
18use ai_agents_skills::{SkillDefinition, SkillLoader};
19use ai_agents_state::{LLMTransitionEvaluator, StateMachine, TransitionEvaluator};
20use ai_agents_template::{TemplateInheritance, TemplateLoader, TemplateRenderer};
21use ai_agents_tools::mcp::view::MCPViewTool;
22use ai_agents_tools::mcp::wrapper::MCPWrapperTool;
23use ai_agents_tools::{ToolRegistry, ToolSecurityEngine, create_builtin_registry};
24
25use super::AgentInfo;
26use super::StreamingConfig;
27use super::runtime::RuntimeAgent;
28use crate::spec::{AgentSpec, StorageConfig};
29
30pub struct AgentBuilder {
31    spec: Option<AgentSpec>,
32    llm: Option<Arc<dyn LLMProvider>>,
33    llm_registry: Option<LLMRegistry>,
34    memory: Option<Arc<dyn Memory>>,
35    tools: Option<ToolRegistry>,
36    skills: Vec<SkillDefinition>,
37    skill_loader: Option<SkillLoader>,
38    yaml_dir: Option<PathBuf>,
39    system_prompt: Option<String>,
40    tools_prompt: Option<String>,
41    auto_tools_prompt: bool,
42    max_iterations: Option<u32>,
43    max_context_tokens: Option<u32>,
44    recovery_manager: Option<RecoveryManager>,
45    tool_security: Option<ToolSecurityEngine>,
46    process_processor: Option<ProcessProcessor>,
47    message_filters: HashMap<String, Arc<dyn MessageFilter>>,
48    context_manager: Option<Arc<ContextManager>>,
49    state_machine: Option<Arc<StateMachine>>,
50    transition_evaluator: Option<Arc<dyn TransitionEvaluator>>,
51    hooks: Option<Arc<dyn AgentHooks>>,
52    hitl_engine: Option<HITLEngine>,
53    approval_handler: Option<Arc<dyn ApprovalHandler>>,
54    storage_config: Option<StorageConfig>,
55    storage: Option<Arc<dyn AgentStorage>>,
56    reasoning: Option<ReasoningConfig>,
57    reflection: Option<ReflectionConfig>,
58    streaming: Option<StreamingConfig>,
59    spawner: Option<Arc<crate::spawner::AgentSpawner>>,
60    spawner_registry: Option<Arc<crate::spawner::AgentRegistry>>,
61    persona_manager: Option<Arc<ai_agents_persona::PersonaManager>>,
62    persona_templates: Option<Arc<ai_agents_persona::PersonaTemplateRegistry>>,
63}
64
65impl AgentBuilder {
66    pub fn new() -> Self {
67        Self {
68            reasoning: None,
69            reflection: None,
70            spec: None,
71            llm: None,
72            llm_registry: None,
73            memory: None,
74            tools: None,
75            skills: Vec::new(),
76            skill_loader: None,
77            yaml_dir: None,
78            system_prompt: None,
79            tools_prompt: None,
80            auto_tools_prompt: true,
81            max_iterations: None,
82            max_context_tokens: None,
83            recovery_manager: None,
84            tool_security: None,
85            process_processor: None,
86            message_filters: HashMap::new(),
87            context_manager: None,
88            state_machine: None,
89            transition_evaluator: None,
90            hooks: None,
91            hitl_engine: None,
92            approval_handler: None,
93            storage_config: None,
94            storage: None,
95            streaming: None,
96            spawner: None,
97            spawner_registry: None,
98            persona_manager: None,
99            persona_templates: None,
100        }
101    }
102
103    pub fn from_spec(spec: AgentSpec) -> Self {
104        let system_prompt = spec.system_prompt.clone();
105        let max_iterations = Some(spec.max_iterations);
106        let max_context_tokens = Some(spec.max_context_tokens);
107        let reasoning = Some(spec.reasoning.clone());
108        let reflection = Some(spec.reflection.clone());
109
110        Self {
111            spec: Some(spec),
112            llm: None,
113            llm_registry: None,
114            memory: None,
115            tools: None,
116            skills: Vec::new(),
117            skill_loader: None,
118            yaml_dir: None,
119            system_prompt: Some(system_prompt),
120            tools_prompt: None,
121            auto_tools_prompt: true,
122            max_iterations,
123            max_context_tokens,
124            recovery_manager: None,
125            tool_security: None,
126            process_processor: None,
127            message_filters: HashMap::new(),
128            context_manager: None,
129            state_machine: None,
130            transition_evaluator: None,
131            hooks: None,
132            hitl_engine: None,
133            approval_handler: None,
134            storage_config: None,
135            storage: None,
136            reasoning,
137            reflection,
138            streaming: None,
139            spawner: None,
140            spawner_registry: None,
141            persona_manager: None,
142            persona_templates: None,
143        }
144    }
145
146    pub fn from_yaml(yaml_content: &str) -> Result<Self> {
147        let spec: AgentSpec = serde_yaml::from_str(yaml_content)?;
148        spec.validate()?;
149        Ok(Self::from_spec(spec))
150    }
151
152    pub fn from_yaml_file(path: impl AsRef<Path>) -> Result<Self> {
153        let path = path.as_ref();
154        let content = std::fs::read_to_string(path).map_err(AgentError::IoError)?;
155        let mut builder = Self::from_yaml(&content)?;
156        if let Some(parent) = path.parent() {
157            builder.yaml_dir = Some(parent.to_path_buf());
158        }
159        Ok(builder)
160    }
161
162    pub fn from_template(template_name: &str) -> Result<Self> {
163        let loader = TemplateLoader::new();
164        Self::from_template_with_loader(template_name, &loader)
165    }
166
167    pub fn from_template_with_loader(template_name: &str, loader: &TemplateLoader) -> Result<Self> {
168        let renderer = TemplateRenderer::new();
169        let variables = loader.variables();
170
171        let load_and_render = |name: &str| -> Result<String> {
172            let content = loader.load_template(name)?;
173            renderer.render(&content, variables)
174        };
175
176        let rendered_root = load_and_render(template_name)?;
177        let processed = TemplateInheritance::process(&rendered_root, load_and_render)?;
178        let spec: AgentSpec = serde_yaml::from_str(&processed)?;
179        spec.validate()?;
180        Ok(Self::from_spec(spec))
181    }
182
183    pub fn auto_configure_llms(mut self) -> Result<Self> {
184        let spec = self
185            .spec
186            .as_ref()
187            .ok_or_else(|| AgentError::Config("Cannot auto-configure LLMs without spec".into()))?;
188
189        if !spec.llms.is_empty() {
190            let mut registry = LLMRegistry::new();
191
192            for (alias, config) in &spec.llms {
193                let provider_type = ProviderType::from_str(&config.provider)
194                    .map_err(|e| AgentError::Config(e.to_string()))?;
195
196                let core_config = ai_agents_core::LLMConfig {
197                    temperature: Some(config.temperature),
198                    max_tokens: Some(config.max_tokens),
199                    top_p: config.top_p,
200                    top_k: None,
201                    frequency_penalty: None,
202                    presence_penalty: None,
203                    stop_sequences: None,
204                    timeout_seconds: config.timeout_seconds,
205                    reasoning: config.reasoning,
206                    reasoning_effort: config.reasoning_effort.clone(),
207                    reasoning_budget_tokens: config.reasoning_budget_tokens,
208                    extra: config.extra.clone(),
209                };
210                // base_url: first-class field, fallback to extra for backward compat
211                let base_url = config.base_url.clone().or_else(|| {
212                    config
213                        .extra
214                        .get("base_url")
215                        .and_then(|v| v.as_str())
216                        .map(|s| s.to_string())
217                });
218
219                // api_key: resolve from api_key_env if specified
220                let api_key = config
221                    .api_key_env
222                    .as_ref()
223                    .and_then(|env_var| std::env::var(env_var).ok());
224
225                let provider = UnifiedLLMProvider::from_spec_config(
226                    provider_type,
227                    &config.model,
228                    api_key,
229                    base_url,
230                    core_config,
231                )
232                .map_err(|e| AgentError::LLM(e.to_string()))?;
233
234                registry.register(alias, Arc::new(provider));
235            }
236
237            let default_alias = spec.llm.get_default_alias();
238            let router_alias = spec.llm.get_router_alias();
239
240            registry.set_default(&default_alias);
241            if let Some(router) = router_alias {
242                registry.set_router(&router);
243            }
244
245            self.llm_registry = Some(registry);
246        } else if let Some(config) = spec.llm.as_config() {
247            let provider_type = ProviderType::from_str(&config.provider)
248                .map_err(|e| AgentError::Config(e.to_string()))?;
249
250            let core_config = ai_agents_core::LLMConfig {
251                temperature: Some(config.temperature),
252                max_tokens: Some(config.max_tokens),
253                top_p: config.top_p,
254                top_k: None,
255                frequency_penalty: None,
256                presence_penalty: None,
257                stop_sequences: None,
258                timeout_seconds: config.timeout_seconds,
259                reasoning: config.reasoning,
260                reasoning_effort: config.reasoning_effort.clone(),
261                reasoning_budget_tokens: config.reasoning_budget_tokens,
262                extra: config.extra.clone(),
263            };
264            // base_url: first-class field, fallback to extra for backward compat
265            let base_url = config.base_url.clone().or_else(|| {
266                config
267                    .extra
268                    .get("base_url")
269                    .and_then(|v| v.as_str())
270                    .map(|s| s.to_string())
271            });
272
273            // api_key: resolve from api_key_env if specified
274            let api_key = config
275                .api_key_env
276                .as_ref()
277                .and_then(|env_var| std::env::var(env_var).ok());
278
279            let provider = UnifiedLLMProvider::from_spec_config(
280                provider_type,
281                &config.model,
282                api_key,
283                base_url,
284                core_config,
285            )
286            .map_err(|e| AgentError::LLM(e.to_string()))?;
287
288            self.llm = Some(Arc::new(provider));
289        }
290
291        Ok(self)
292    }
293
294    /// Auto-configure recovery, tool security, process pipeline, and built-in tools from the spec.
295    ///
296    /// **Call order matters for tools**: this method only registers built-in tools when `self.tools` is `None`.
297    /// If `.tool()` or `.tools()` was called before this, `self.tools` is already `Some` and built-ins will NOT be added.
298    ///
299    /// Correct:
300    /// ```ignore
301    /// .auto_configure_features()?   // registers built-ins (self.tools was None)
302    /// .tool(Arc::new(MyTool))       // adds MyTool into the builtin registry
303    /// ```
304    ///
305    /// Wrong — built-ins are lost:
306    /// ```ignore
307    /// .tool(Arc::new(MyTool))       // self.tools = Some(empty + MyTool)
308    /// .auto_configure_features()?   // self.tools is Some -> skips builtin registration
309    /// ```
310    pub fn auto_configure_features(mut self) -> Result<Self> {
311        if let Some(ref spec) = self.spec {
312            self.recovery_manager = Some(RecoveryManager::new(spec.error_recovery.clone()));
313            self.tool_security = Some(ToolSecurityEngine::new(spec.tool_security.clone()));
314
315            if spec.has_process() {
316                let mut processor = ProcessProcessor::new(spec.process.clone());
317                if let Some(ref registry) = self.llm_registry {
318                    processor = processor.with_llm_registry(Arc::new(registry.clone()));
319                }
320                self.process_processor = Some(processor);
321            }
322
323            // Auto-register builtin tools if the user hasn't provided a custom registry.
324            if self.tools.is_none() {
325                self.tools = Some(create_builtin_registry());
326            }
327        }
328        Ok(self)
329    }
330
331    /// Initialize MCP wrapper tools from `tools:` entries with `type: mcp`.
332    ///
333    /// Each MCP entry becomes an `MCPWrapperTool` registered as a normal builtin
334    /// tool in the `ToolRegistry`. Views defined in the entry's `views:` field are registered as separate `MCPViewTool` instances sharing the parent's MCP connection.
335    ///
336    /// Call this after `auto_configure_features()` so the tool registry exists.
337    pub async fn auto_configure_mcp(mut self) -> Result<Self> {
338        if let Some(ref spec) = self.spec {
339            // Collect MCP configs from tools: entries with type: mcp
340            let mcp_configs: Vec<_> = spec
341                .tools
342                .as_ref()
343                .map(|tools| {
344                    tools
345                        .iter()
346                        .filter_map(|entry| entry.to_mcp_config())
347                        .collect()
348                })
349                .unwrap_or_default();
350
351            if !mcp_configs.is_empty() {
352                let registry = self.tools.get_or_insert_with(create_builtin_registry);
353
354                for config in mcp_configs {
355                    let tool_name = config.name.clone();
356                    let timeout_ms = config.startup_timeout_ms;
357                    let views_config = config.views.clone();
358
359                    let wrapper = MCPWrapperTool::new(config);
360
361                    // Initialize with timeout
362                    match tokio::time::timeout(
363                        std::time::Duration::from_millis(timeout_ms),
364                        wrapper.initialized(),
365                    )
366                    .await
367                    {
368                        Ok(Ok(initialized_tool)) => {
369                            tracing::info!(
370                                tool = %tool_name,
371                                functions = initialized_tool.function_count(),
372                                "MCP wrapper tool registered"
373                            );
374
375                            let parent = Arc::new(initialized_tool);
376
377                            // Register the parent tool
378                            registry.register(parent.clone()).map_err(|e| {
379                                AgentError::Config(format!(
380                                    "Failed to register MCP tool '{}': {}",
381                                    tool_name, e
382                                ))
383                            })?;
384
385                            // Register views as separate tools sharing the parent connection
386                            for (view_name, view_config) in &views_config {
387                                let view_tool = MCPViewTool::new(
388                                    view_name.clone(),
389                                    parent.clone(),
390                                    view_config.functions.clone(),
391                                    view_config.description.clone(),
392                                )
393                                .map_err(|e| {
394                                    AgentError::Config(format!(
395                                        "Failed to create MCP view '{}': {}",
396                                        view_name, e
397                                    ))
398                                })?;
399
400                                tracing::info!(
401                                    view = %view_name,
402                                    parent = %tool_name,
403                                    functions = view_config.functions.len(),
404                                    "MCP view tool registered"
405                                );
406
407                                registry.register(Arc::new(view_tool)).map_err(|e| {
408                                    AgentError::Config(format!(
409                                        "Failed to register MCP view '{}': {}",
410                                        view_name, e
411                                    ))
412                                })?;
413                            }
414                        }
415                        Ok(Err(e)) => {
416                            return Err(AgentError::Config(format!(
417                                "MCP tool '{}' initialization failed: {}",
418                                tool_name, e
419                            )));
420                        }
421                        Err(_) => {
422                            return Err(AgentError::Config(format!(
423                                "MCP tool '{}' timed out after {}ms",
424                                tool_name, timeout_ms
425                            )));
426                        }
427                    }
428                }
429            }
430        }
431        Ok(self)
432    }
433
434    pub fn llm(mut self, llm: Arc<dyn LLMProvider>) -> Self {
435        self.llm = Some(llm);
436        self
437    }
438
439    pub fn llm_alias(mut self, alias: impl Into<String>, provider: Arc<dyn LLMProvider>) -> Self {
440        if self.llm_registry.is_none() {
441            self.llm_registry = Some(LLMRegistry::new());
442        }
443        if let Some(ref mut registry) = self.llm_registry {
444            registry.register(alias, provider);
445        }
446        self
447    }
448
449    pub fn llm_registry(mut self, registry: LLMRegistry) -> Self {
450        self.llm_registry = Some(registry);
451        self
452    }
453
454    pub fn memory(mut self, memory: Arc<dyn Memory>) -> Self {
455        self.memory = Some(memory);
456        self
457    }
458
459    /// Replace the entire tool registry.
460    ///
461    /// If `auto_configure_features()` was called before this, the auto-registered builtins will be overwritten.
462    pub fn tools(mut self, tools: ToolRegistry) -> Self {
463        self.tools = Some(tools);
464        self
465    }
466
467    /// Register a single tool into the existing registry.
468    ///
469    /// If no registry exists yet, creates an empty one first.
470    /// Use this to add custom tools on top of auto-configured builtins.
471    pub fn tool(mut self, tool: Arc<dyn Tool>) -> Self {
472        let registry = self.tools.get_or_insert_with(ToolRegistry::new);
473        let _ = registry.register(tool);
474        self
475    }
476
477    /// Merge tools from another registry into the existing one.
478    ///
479    /// Skips tools whose ID already exists (no overwrite).
480    /// If no registry exists yet, creates an empty one first.
481    pub fn extend_tools(mut self, additional: ToolRegistry) -> Self {
482        let registry = self.tools.get_or_insert_with(ToolRegistry::new);
483        for id in additional.list_ids() {
484            if registry.get(&id).is_none() {
485                if let Some(tool) = additional.get(&id) {
486                    let _ = registry.register(tool);
487                }
488            }
489        }
490        self
491    }
492
493    pub fn skill(mut self, skill: SkillDefinition) -> Self {
494        self.skills.push(skill);
495        self
496    }
497
498    pub fn skills(mut self, skills: Vec<SkillDefinition>) -> Self {
499        self.skills.extend(skills);
500        self
501    }
502
503    pub fn skill_loader(mut self, loader: SkillLoader) -> Self {
504        self.skill_loader = Some(loader);
505        self
506    }
507
508    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
509        self.system_prompt = Some(prompt.into());
510        self
511    }
512
513    pub fn tools_prompt(mut self, prompt: impl Into<String>) -> Self {
514        self.tools_prompt = Some(prompt.into());
515        self.auto_tools_prompt = false;
516        self
517    }
518
519    pub fn auto_tools_prompt(mut self, auto: bool) -> Self {
520        self.auto_tools_prompt = auto;
521        self
522    }
523
524    pub fn max_iterations(mut self, max: u32) -> Self {
525        self.max_iterations = Some(max);
526        self
527    }
528
529    pub fn max_context_tokens(mut self, tokens: u32) -> Self {
530        self.max_context_tokens = Some(tokens);
531        self
532    }
533
534    pub fn recovery_manager(mut self, manager: RecoveryManager) -> Self {
535        self.recovery_manager = Some(manager);
536        self
537    }
538
539    pub fn tool_security(mut self, engine: ToolSecurityEngine) -> Self {
540        self.tool_security = Some(engine);
541        self
542    }
543
544    pub fn process_processor(mut self, processor: ProcessProcessor) -> Self {
545        self.process_processor = Some(processor);
546        self
547    }
548
549    pub fn message_filter(
550        mut self,
551        name: impl Into<String>,
552        filter: Arc<dyn MessageFilter>,
553    ) -> Self {
554        self.message_filters.insert(name.into(), filter);
555        self
556    }
557
558    pub fn context_manager(mut self, manager: Arc<ContextManager>) -> Self {
559        self.context_manager = Some(manager);
560        self
561    }
562
563    pub fn state_machine(mut self, machine: Arc<StateMachine>) -> Self {
564        self.state_machine = Some(machine);
565        self
566    }
567
568    pub fn transition_evaluator(mut self, evaluator: Arc<dyn TransitionEvaluator>) -> Self {
569        self.transition_evaluator = Some(evaluator);
570        self
571    }
572
573    pub fn hooks(mut self, hooks: Arc<dyn AgentHooks>) -> Self {
574        self.hooks = Some(hooks);
575        self
576    }
577
578    pub fn approval_handler(mut self, handler: Arc<dyn ApprovalHandler>) -> Self {
579        self.approval_handler = Some(handler);
580        self
581    }
582
583    pub fn hitl_engine(mut self, engine: HITLEngine) -> Self {
584        self.hitl_engine = Some(engine);
585        self
586    }
587
588    pub fn storage_config(mut self, config: StorageConfig) -> Self {
589        self.storage_config = Some(config);
590        self
591    }
592
593    pub fn storage(mut self, storage: Arc<dyn AgentStorage>) -> Self {
594        self.storage = Some(storage);
595        self
596    }
597
598    pub fn reasoning(mut self, config: ReasoningConfig) -> Self {
599        self.reasoning = Some(config);
600        self
601    }
602
603    pub fn reflection(mut self, config: ReflectionConfig) -> Self {
604        self.reflection = Some(config);
605        self
606    }
607
608    /// Set persona config directly (overrides spec).
609    pub fn persona(mut self, manager: Arc<ai_agents_persona::PersonaManager>) -> Self {
610        self.persona_manager = Some(manager);
611        self
612    }
613
614    /// Provide a shared persona template registry.
615    pub fn persona_templates(
616        mut self,
617        registry: Arc<ai_agents_persona::PersonaTemplateRegistry>,
618    ) -> Self {
619        self.persona_templates = Some(registry);
620        self
621    }
622
623    pub fn streaming(mut self, enabled: bool) -> Self {
624        let mut config = self.streaming.unwrap_or_default();
625        config.enabled = enabled;
626        self.streaming = Some(config);
627        self
628    }
629
630    /// Wire spawner tools when the spec has a `spawner:` section.
631    /// Call after `auto_configure_llms()` and `auto_configure_features()`.
632    pub async fn auto_configure_spawner(mut self) -> Result<Self> {
633        let spawner_config = match self.spec.as_ref().and_then(|s| s.spawner.as_ref()) {
634            Some(c) => c.clone(),
635            None => return Ok(self),
636        };
637
638        use crate::spawner::{
639            AgentRegistry, AgentSpawner,
640            config::{configure_spawner_tools, resolve_templates},
641        };
642
643        let mut spawner = AgentSpawner::new();
644
645        if spawner_config.shared_llms {
646            if let Some(ref reg) = self.llm_registry {
647                spawner = spawner.with_shared_llms(reg.clone());
648            }
649        }
650
651        if !spawner_config.shared_context.is_empty() {
652            spawner = spawner.with_shared_context_map(spawner_config.shared_context.clone());
653        }
654
655        if let Some(max) = spawner_config.max_agents {
656            spawner = spawner.with_max_agents(max);
657        }
658
659        if let Some(ref prefix) = spawner_config.name_prefix {
660            spawner = spawner.with_name_prefix(prefix.clone());
661        }
662
663        // Resolve file-path templates against the parent YAML directory.
664        if !spawner_config.templates.is_empty() {
665            let resolved = resolve_templates(&spawner_config.templates, self.yaml_dir.as_deref())?;
666            spawner = spawner.with_templates(resolved);
667        }
668
669        if let Some(ref allowed) = spawner_config.allowed_tools {
670            spawner = spawner.with_allowed_tools(allowed.clone());
671        }
672
673        // Resolve shared storage from YAML config into a live backend.
674        if let Some(ref sc) = spawner_config.shared_storage {
675            let converted = crate::spec::storage::to_storage_config(sc);
676            if let Some(st) = ai_agents_storage::create_storage(&converted).await? {
677                spawner = spawner.with_shared_storage(Arc::clone(&st));
678
679                // Auto-inject into parent when no explicit storage: is configured.
680                let parent_has_storage = self.storage.is_some()
681                    || self.storage_config.is_some()
682                    || self.spec.as_ref().map_or(false, |s| s.has_storage());
683                if !parent_has_storage {
684                    self.storage = Some(st);
685                }
686            }
687        }
688
689        let spawner = Arc::new(spawner);
690        let registry = Arc::new(AgentRegistry::new());
691
692        self.spawner = Some(Arc::clone(&spawner));
693        self.spawner_registry = Some(Arc::clone(&registry));
694        let llm_for_tools = Arc::new(self.llm_registry.clone().unwrap_or_default());
695        let agent_name = self
696            .spec
697            .as_ref()
698            .map(|s| s.name.clone())
699            .unwrap_or_default();
700
701        let tools = configure_spawner_tools(
702            Arc::clone(&spawner),
703            Arc::clone(&registry),
704            Arc::clone(&llm_for_tools),
705            &agent_name,
706        );
707
708        let tool_registry = self.tools.get_or_insert_with(create_builtin_registry);
709        for tool in tools {
710            let _ = tool_registry.register(tool);
711        }
712
713        tracing::info!("Spawner tools registered");
714
715        // Register orchestration tools if configured.
716        if spawner_config.orchestration_tools.is_enabled() {
717            let orch_tools = crate::orchestration::tools::configure_orchestration_tools(
718                &spawner_config.orchestration_tools,
719                Arc::clone(&registry),
720                Arc::clone(&llm_for_tools),
721            );
722            let tool_registry = self.tools.get_or_insert_with(create_builtin_registry);
723            for tool in orch_tools {
724                let _ = tool_registry.register(tool);
725            }
726            tracing::info!("Orchestration tools registered");
727        }
728
729        // Auto-spawn agents from YAML files and register them.
730        for entry in &spawner_config.auto_spawn {
731            let yaml_path = if let Some(ref dir) = self.yaml_dir {
732                dir.join(&entry.agent)
733            } else {
734                std::path::PathBuf::from(&entry.agent)
735            };
736
737            tracing::info!(id = %entry.id, path = %yaml_path.display(), "Auto-spawning agent");
738
739            match AgentBuilder::from_yaml_file(&yaml_path) {
740                Ok(mut sub_builder) => {
741                    if spawner_config.shared_llms {
742                        if let Some(ref reg) = self.llm_registry {
743                            sub_builder = sub_builder.llm_registry(reg.clone());
744                        }
745                    }
746                    match sub_builder
747                        .auto_configure_llms()
748                        .and_then(|b| b.auto_configure_features())
749                    {
750                        Ok(configured) => match configured.build() {
751                            Ok(agent) => {
752                                let spec_yaml =
753                                    std::fs::read_to_string(&yaml_path).unwrap_or_default();
754                                let spec: crate::spec::AgentSpec =
755                                    serde_yaml::from_str(&spec_yaml).unwrap_or_default();
756
757                                let spawned = crate::spawner::spawner::SpawnedAgent {
758                                    id: entry.id.clone(),
759                                    agent: Arc::new(agent),
760                                    spec,
761                                    spawned_at: chrono::Utc::now(),
762                                };
763
764                                if let Err(e) = registry.register(spawned).await {
765                                    tracing::warn!(
766                                        id = %entry.id,
767                                        error = %e,
768                                        "Failed to register auto-spawned agent"
769                                    );
770                                } else {
771                                    tracing::info!(id = %entry.id, "Auto-spawned agent registered");
772                                }
773                            }
774                            Err(e) => {
775                                tracing::warn!(
776                                    id = %entry.id,
777                                    error = %e,
778                                    "Failed to build auto-spawn agent"
779                                );
780                            }
781                        },
782                        Err(e) => {
783                            tracing::warn!(
784                                id = %entry.id,
785                                error = %e,
786                                "Failed to configure auto-spawn agent"
787                            );
788                        }
789                    }
790                }
791                Err(e) => {
792                    tracing::warn!(
793                        id = %entry.id,
794                        path = %yaml_path.display(),
795                        error = %e,
796                        "Failed to load auto-spawn YAML"
797                    );
798                }
799            }
800        }
801
802        // Validate that all orchestration state references have matching agents.
803        if let Some(ref spec) = self.spec {
804            if let Some(ref state_config) = spec.states {
805                let refs = collect_orchestration_refs(&state_config.states);
806                let mut missing: Vec<String> = Vec::new();
807
808                for (agent_id, state_name, pattern) in &refs {
809                    if !registry.contains(agent_id) {
810                        missing.push(format!(
811                            "  - '{}' (referenced by state '{}' via {})",
812                            agent_id, state_name, pattern
813                        ));
814                    }
815                }
816
817                if !missing.is_empty() {
818                    missing.sort();
819                    missing.dedup();
820                    return Err(AgentError::Config(format!(
821                        "Auto-spawn validation failed. These agents are referenced by \
822                         orchestration states but were not successfully spawned:\n\n{}\n\n\
823                         Check that agent YAML files exist and contain valid specs.",
824                        missing.join("\n")
825                    )));
826                }
827            }
828        }
829
830        Ok(self)
831    }
832
833    pub fn build(mut self) -> Result<RuntimeAgent> {
834        let base_prompt = self
835            .system_prompt
836            .ok_or_else(|| AgentError::Config("System prompt is required".into()))?;
837
838        let mut tools = self.tools.unwrap_or_else(ToolRegistry::new);
839
840        // ERROR NOTE: Don't include tools prompt here
841        // - it will be added AFTER template rendering in get_effective_system_prompt() to avoid Jinja2 parsing JSON braces
842        let system_prompt = base_prompt;
843
844        let max_iterations = self.max_iterations.unwrap_or(10);
845
846        let info = if let Some(ref spec) = self.spec {
847            AgentInfo::new(&spec.name, &spec.name, &spec.version)
848                .with_description(spec.description.clone().unwrap_or_default())
849        } else {
850            AgentInfo::new("agent", "Agent", "1.0.0")
851        };
852
853        if let Some(ref spec) = self.spec {
854            if !spec.skills.is_empty() {
855                let mut loader = self.skill_loader.take().unwrap_or_else(SkillLoader::new);
856                if let Some(ref dir) = self.yaml_dir {
857                    loader.set_base_dir(dir);
858                }
859                let loaded_skills = loader.load_refs(&spec.skills)?;
860                self.skills.extend(loaded_skills);
861            }
862        }
863
864        let mut llm_registry = self.llm_registry.unwrap_or_else(LLMRegistry::new);
865
866        if let Some(llm) = self.llm {
867            if !llm_registry.has("default") {
868                llm_registry.register("default", llm.clone());
869            }
870        }
871
872        if let Some(ref spec) = self.spec {
873            let default_alias = spec.llm.get_default_alias();
874            let router_alias = spec.llm.get_router_alias();
875
876            llm_registry.set_default(&default_alias);
877            if let Some(router) = router_alias {
878                llm_registry.set_router(&router);
879            }
880        }
881
882        if llm_registry.is_empty() {
883            return Err(AgentError::Config(
884                "At least one LLM provider is required".into(),
885            ));
886        }
887
888        // Create memory after LLM registry is ready (needed for CompactingMemory summarizer)
889        let memory = self.memory.unwrap_or_else(|| {
890            if let Some(ref spec) = self.spec {
891                if spec.memory.is_compacting() {
892                    let summarizer_llm = spec
893                        .memory
894                        .summarizer_llm
895                        .as_ref()
896                        .and_then(|alias| llm_registry.get(alias).ok())
897                        .or_else(|| llm_registry.router().ok())
898                        .or_else(|| llm_registry.default().ok());
899
900                    let summarizer: Arc<dyn Summarizer> = match summarizer_llm {
901                        Some(llm) => Arc::new(LLMSummarizer::new(llm)),
902                        None => Arc::new(NoopSummarizer),
903                    };
904                    let config = spec.memory.to_compacting_config();
905                    return Arc::new(CompactingMemory::new(summarizer, config));
906                }
907                Arc::new(InMemoryStore::new(spec.memory.max_messages))
908            } else {
909                Arc::new(InMemoryStore::new(100))
910            }
911        });
912
913        // Configure persona before freezing tools (evolve tool may need registration).
914        let persona_manager: Option<Arc<ai_agents_persona::PersonaManager>> =
915            if let Some(pm) = self.persona_manager.take() {
916                Some(pm)
917            } else if let Some(ref spec) = self.spec {
918                if spec.has_persona() {
919                    let persona_config = spec.persona.clone().unwrap();
920                    let renderer = ai_agents_context::TemplateRenderer::new();
921                    let registry = self.persona_templates.clone();
922                    let manager = ai_agents_persona::PersonaManager::from_config(
923                        persona_config,
924                        registry,
925                        renderer,
926                    )
927                    .map_err(|e| {
928                        AgentError::Config(format!("Failed to create PersonaManager: {}", e))
929                    })?;
930                    Some(Arc::new(manager))
931                } else {
932                    None
933                }
934            } else {
935                None
936            };
937
938        // Register persona_evolve tool if allow_llm_evolve is true.
939        if let Some(ref pm) = persona_manager {
940            if pm.should_register_evolve_tool() {
941                let evolve_tool = ai_agents_persona::PersonaEvolveTool::new(pm.clone());
942                let _ = tools.register(Arc::new(evolve_tool));
943            }
944        }
945
946        let tools_arc = Arc::new(tools);
947        let llm_registry_arc = Arc::new(llm_registry);
948
949        // Extract declared tool IDs from spec (agent-level tool scope)
950        // - None  = `tools:` not specified in YAML -> all registered tools available : WILL CHANGE as no tools (Future task)
951        // - Some([]) = `tools: []` in YAML -> explicitly no tools
952        // - Some([...]) = specific tools listed
953        let declared_tool_ids: Option<Vec<String>> = self.spec.as_ref().and_then(|s| {
954            s.tools.as_ref().map(|tools| {
955                let mut ids: Vec<String> = tools.iter().map(|t| t.name().to_string()).collect();
956
957                // Include view names from MCP entries so they pass availability checks
958                for entry in tools {
959                    if let Some(mcp_config) = entry.to_mcp_config() {
960                        for view_name in mcp_config.views.keys() {
961                            ids.push(view_name.clone());
962                        }
963                    }
964                }
965
966                // Auto-registered tools must bypass scoping when the user has an explicit list.
967                // persona_evolve is auto-registered when allow_llm_evolve is true.
968                if persona_manager
969                    .as_ref()
970                    .is_some_and(|pm| pm.should_register_evolve_tool())
971                {
972                    ids.push("persona_evolve".to_string());
973                }
974
975                ids
976            })
977        });
978
979        // Validate: every non-MCP tool declared in the YAML spec must exist in the registry.
980        // MCP tools are excluded because they are registered via auto_configure_mcp() which
981        // may or may not have been called (and MCP view names are synthetic).
982        if let Some(ref ids) = declared_tool_ids {
983            let mcp_names: Vec<String> = self
984                .spec
985                .as_ref()
986                .and_then(|s| s.tools.as_ref())
987                .map(|tools| {
988                    let mut names = Vec::new();
989                    for entry in tools {
990                        if entry.is_mcp() {
991                            names.push(entry.name().to_string());
992                            if let Some(cfg) = entry.to_mcp_config() {
993                                names.extend(cfg.views.keys().cloned());
994                            }
995                        }
996                    }
997                    names
998                })
999                .unwrap_or_default();
1000
1001            let missing: Vec<&str> = ids
1002                .iter()
1003                .filter(|id| !mcp_names.contains(id))
1004                .filter(|id| tools_arc.get(id).is_none())
1005                .map(|s| s.as_str())
1006                .collect();
1007
1008            if !missing.is_empty() {
1009                return Err(AgentError::Config(format!(
1010                    "Tools declared in YAML but not registered: [{}]. \
1011                     Register them via .tool(Arc::new(...)) before .build(), \
1012                     or remove them from the YAML tools: list.",
1013                    missing.join(", ")
1014                )));
1015            }
1016        }
1017
1018        let mut agent = RuntimeAgent::new(
1019            info,
1020            llm_registry_arc.clone(),
1021            memory,
1022            tools_arc,
1023            self.skills,
1024            system_prompt,
1025            max_iterations,
1026        )
1027        .with_declared_tool_ids(declared_tool_ids);
1028
1029        if let Some(tokens) = self.max_context_tokens {
1030            agent = agent.with_max_context_tokens(tokens);
1031        }
1032
1033        if let Some(manager) = self.recovery_manager {
1034            agent = agent.with_recovery_manager(manager);
1035        } else if let Some(ref spec) = self.spec {
1036            agent = agent.with_recovery_manager(RecoveryManager::new(spec.error_recovery.clone()));
1037        }
1038
1039        if let Some(engine) = self.tool_security {
1040            agent = agent.with_tool_security(engine);
1041        } else if let Some(ref spec) = self.spec {
1042            agent = agent.with_tool_security(ToolSecurityEngine::new(spec.tool_security.clone()));
1043        }
1044
1045        if let Some(processor) = self.process_processor {
1046            agent = agent.with_process_processor(processor);
1047        } else if let Some(ref spec) = self.spec {
1048            if spec.has_process() {
1049                let processor = ProcessProcessor::new(spec.process.clone())
1050                    .with_llm_registry(llm_registry_arc.clone());
1051                agent = agent.with_process_processor(processor);
1052            }
1053        }
1054
1055        for (name, filter) in self.message_filters {
1056            agent.register_message_filter(name, filter);
1057        }
1058
1059        // Configure state machine from spec or builder
1060        if let Some(state_machine) = self.state_machine {
1061            let evaluator = self.transition_evaluator.unwrap_or_else(|| {
1062                let eval_llm = llm_registry_arc
1063                    .get("evaluator")
1064                    .or_else(|_| llm_registry_arc.router())
1065                    .or_else(|_| llm_registry_arc.default())
1066                    .expect("At least one LLM required for transition evaluator");
1067                Arc::new(LLMTransitionEvaluator::new(eval_llm))
1068            });
1069            agent = agent.with_state_machine(state_machine, evaluator);
1070        } else if let Some(ref spec) = self.spec {
1071            if let Some(ref state_config) = spec.states {
1072                let state_machine = StateMachine::new(state_config.clone())?;
1073                let evaluator = self.transition_evaluator.unwrap_or_else(|| {
1074                    let eval_llm = llm_registry_arc
1075                        .get("evaluator")
1076                        .or_else(|_| llm_registry_arc.router())
1077                        .or_else(|_| llm_registry_arc.default())
1078                        .expect("At least one LLM required for transition evaluator");
1079                    Arc::new(LLMTransitionEvaluator::new(eval_llm))
1080                });
1081                agent = agent.with_state_machine(Arc::new(state_machine), evaluator);
1082            }
1083        }
1084
1085        // Configure context manager from spec or builder
1086        if let Some(context_manager) = self.context_manager {
1087            agent = agent.with_context_manager(context_manager);
1088        } else if let Some(ref spec) = self.spec {
1089            if !spec.context.is_empty() {
1090                let context_manager = ContextManager::new(
1091                    spec.context.clone(),
1092                    spec.name.clone(),
1093                    spec.version.clone(),
1094                );
1095                agent = agent.with_context_manager(Arc::new(context_manager));
1096            }
1097        }
1098
1099        // Configure parallel tools and streaming from spec
1100        if let Some(ref spec) = self.spec {
1101            agent = agent.with_parallel_tools(spec.parallel_tools.clone());
1102            let streaming_config = self
1103                .streaming
1104                .clone()
1105                .unwrap_or_else(|| spec.streaming.clone());
1106            agent = agent.with_streaming(streaming_config);
1107
1108            // Configure memory token budget if specified
1109            if let Some(ref budget) = spec.memory.token_budget {
1110                agent = agent.with_memory_token_budget(budget.clone());
1111            }
1112
1113            // Configure storage from spec if not explicitly set
1114            if self.storage_config.is_none() && spec.has_storage() {
1115                agent = agent.with_storage_config(spec.storage.clone());
1116            }
1117        }
1118
1119        // Configure storage from builder
1120        if let Some(storage_config) = self.storage_config {
1121            agent = agent.with_storage_config(storage_config);
1122        }
1123        if let Some(storage) = self.storage {
1124            agent = agent.with_storage(storage);
1125        }
1126
1127        // Wire spawner handles into the agent so CLI can access registry.
1128        if let (Some(spawner), Some(registry)) = (self.spawner, self.spawner_registry) {
1129            agent = agent.with_spawner_handles(spawner, registry);
1130        }
1131
1132        // Configure hooks
1133        if let Some(hooks) = self.hooks {
1134            agent = agent.with_hooks(hooks);
1135        }
1136
1137        // Configure HITL from spec or builder
1138        if let Some(hitl_engine) = self.hitl_engine {
1139            let handler = self
1140                .approval_handler
1141                .unwrap_or_else(|| Arc::new(RejectAllHandler::new()));
1142            agent = agent.with_hitl(hitl_engine, handler);
1143        } else if let Some(ref spec) = self.spec {
1144            if let Some(ref hitl_config) = spec.hitl {
1145                let hitl_engine = HITLEngine::new(hitl_config.clone());
1146                let handler = self
1147                    .approval_handler
1148                    .unwrap_or_else(|| Arc::new(RejectAllHandler::new()));
1149                agent = agent.with_hitl(hitl_engine, handler);
1150            }
1151        }
1152
1153        if let Some(reasoning) = self.reasoning {
1154            agent = agent.with_reasoning(reasoning);
1155        } else if let Some(ref spec) = self.spec {
1156            agent = agent.with_reasoning(spec.reasoning.clone());
1157        }
1158
1159        if let Some(reflection) = self.reflection {
1160            agent = agent.with_reflection(reflection);
1161        } else if let Some(ref spec) = self.spec {
1162            agent = agent.with_reflection(spec.reflection.clone());
1163        }
1164
1165        // Configure disambiguation from spec
1166        if let Some(ref spec) = self.spec {
1167            if spec.disambiguation.is_enabled() {
1168                agent = agent.with_disambiguation(spec.disambiguation.clone());
1169            }
1170        }
1171
1172        // Wire persona manager into the agent (created earlier before tools_arc).
1173        if let Some(pm) = persona_manager {
1174            agent = agent.with_persona(pm);
1175        }
1176
1177        Ok(agent)
1178    }
1179}
1180
1181/// Collect all agent IDs referenced by orchestration state fields.
1182fn collect_orchestration_refs(
1183    states: &std::collections::HashMap<String, ai_agents_state::StateDefinition>,
1184) -> Vec<(String, String, &'static str)> {
1185    let mut refs = Vec::new();
1186
1187    for (state_name, def) in states {
1188        if let Some(ref delegate_id) = def.delegate {
1189            refs.push((delegate_id.clone(), state_name.clone(), "delegate"));
1190        }
1191        if let Some(ref concurrent) = def.concurrent {
1192            for agent_ref in &concurrent.agents {
1193                refs.push((agent_ref.id().to_string(), state_name.clone(), "concurrent"));
1194            }
1195        }
1196        if let Some(ref gc) = def.group_chat {
1197            for participant in &gc.participants {
1198                refs.push((participant.id.clone(), state_name.clone(), "group_chat"));
1199            }
1200        }
1201        if let Some(ref pipeline) = def.pipeline {
1202            for stage in &pipeline.stages {
1203                refs.push((stage.id().to_string(), state_name.clone(), "pipeline"));
1204            }
1205        }
1206        if let Some(ref handoff) = def.handoff {
1207            refs.push((handoff.initial_agent.clone(), state_name.clone(), "handoff"));
1208            for agent_id in &handoff.available_agents {
1209                refs.push((agent_id.clone(), state_name.clone(), "handoff"));
1210            }
1211        }
1212
1213        // Recurse into sub-states.
1214        if let Some(ref sub_states) = def.states {
1215            refs.extend(collect_orchestration_refs(sub_states));
1216        }
1217    }
1218
1219    refs
1220}
1221
1222impl Default for AgentBuilder {
1223    fn default() -> Self {
1224        Self::new()
1225    }
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230    use super::*;
1231
1232    #[test]
1233    fn test_builder_new() {
1234        let builder = AgentBuilder::new();
1235        assert!(builder.spec.is_none());
1236        assert!(builder.system_prompt.is_none());
1237    }
1238
1239    #[test]
1240    fn test_builder_from_yaml() {
1241        let yaml = r#"
1242name: TestAgent
1243system_prompt: "You are helpful."
1244llm:
1245  provider: openai
1246  model: gpt-4
1247"#;
1248        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1249        assert!(builder.spec.is_some());
1250        assert_eq!(builder.spec.as_ref().unwrap().name, "TestAgent");
1251    }
1252
1253    #[test]
1254    fn test_builder_from_yaml_with_tool_security() {
1255        let yaml = r#"
1256name: SecureAgent
1257system_prompt: "You are helpful."
1258llm:
1259  provider: openai
1260  model: gpt-4
1261max_context_tokens: 8192
1262error_recovery:
1263  default:
1264    max_retries: 5
1265tool_security:
1266  enabled: true
1267  tools:
1268    http:
1269      rate_limit: 10
1270"#;
1271        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1272        assert!(builder.spec.is_some());
1273        let spec = builder.spec.as_ref().unwrap();
1274        assert_eq!(spec.max_context_tokens, 8192);
1275        assert_eq!(spec.error_recovery.default.max_retries, 5);
1276        assert!(spec.tool_security.enabled);
1277    }
1278
1279    #[test]
1280    fn test_builder_from_yaml_with_skills() {
1281        let yaml = r#"
1282name: SkillAgent
1283system_prompt: "You are helpful."
1284llm:
1285  provider: openai
1286  model: gpt-4
1287skills:
1288  - id: greeting
1289    description: "Greet users"
1290    trigger: "When user says hello"
1291    steps:
1292      - prompt: "Hello!"
1293"#;
1294        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1295        assert!(builder.spec.is_some());
1296        assert!(!builder.spec.as_ref().unwrap().skills.is_empty());
1297    }
1298
1299    #[test]
1300    fn test_builder_from_spec() {
1301        let spec = AgentSpec {
1302            name: "test".to_string(),
1303            version: "1.0".to_string(),
1304            description: Some("Test agent".to_string()),
1305            system_prompt: "You are helpful".to_string(),
1306            ..Default::default()
1307        };
1308
1309        let builder = AgentBuilder::from_spec(spec);
1310        assert!(builder.spec.is_some());
1311        assert_eq!(builder.system_prompt, Some("You are helpful".to_string()));
1312    }
1313
1314    #[test]
1315    fn test_builder_chain() {
1316        let builder = AgentBuilder::new()
1317            .system_prompt("Test prompt")
1318            .max_iterations(5)
1319            .max_context_tokens(4096);
1320
1321        assert_eq!(builder.system_prompt, Some("Test prompt".to_string()));
1322        assert_eq!(builder.max_iterations, Some(5));
1323        assert_eq!(builder.max_context_tokens, Some(4096));
1324    }
1325
1326    #[test]
1327    fn test_builder_skills() {
1328        use ai_agents_skills::{SkillDefinition, SkillStep};
1329
1330        let skill = SkillDefinition {
1331            id: "test".to_string(),
1332            description: "Test skill".to_string(),
1333            trigger: "When testing".to_string(),
1334            steps: vec![SkillStep::Prompt {
1335                prompt: "Hello".to_string(),
1336                llm: None,
1337            }],
1338            reasoning: None,
1339            reflection: None,
1340            disambiguation: None,
1341        };
1342
1343        let builder = AgentBuilder::new().skill(skill.clone()).skills(vec![skill]);
1344
1345        assert_eq!(builder.skills.len(), 2);
1346    }
1347
1348    #[test]
1349    fn test_builder_from_yaml_with_states() {
1350        let yaml = r#"
1351name: StatefulAgent
1352system_prompt: "You are helpful."
1353llm:
1354  provider: openai
1355  model: gpt-4
1356states:
1357  initial: greeting
1358  states:
1359    greeting:
1360      prompt: "Welcome!"
1361      transitions:
1362        - to: support
1363          when: "user needs help"
1364    support:
1365      prompt: "How can I help?"
1366"#;
1367        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1368        assert!(builder.spec.is_some());
1369        let spec = builder.spec.as_ref().unwrap();
1370        assert!(spec.has_states());
1371        assert!(spec.states.is_some());
1372        let states = spec.states.as_ref().unwrap();
1373        assert_eq!(states.initial, "greeting");
1374        assert_eq!(states.states.len(), 2);
1375    }
1376
1377    #[test]
1378    fn test_builder_from_yaml_with_context() {
1379        let yaml = r#"
1380name: ContextAgent
1381system_prompt: "Hello, {{ context.user.name }}!"
1382llm:
1383  provider: openai
1384  model: gpt-4
1385context:
1386  user:
1387    type: runtime
1388    required: true
1389  time:
1390    type: builtin
1391    source: datetime
1392    refresh: per_turn
1393"#;
1394        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1395        assert!(builder.spec.is_some());
1396        let spec = builder.spec.as_ref().unwrap();
1397        assert!(spec.has_context());
1398        assert_eq!(spec.context.len(), 2);
1399        assert!(spec.context.contains_key("user"));
1400        assert!(spec.context.contains_key("time"));
1401    }
1402
1403    #[test]
1404    fn test_builder_from_yaml_with_full_v04_features() {
1405        let yaml = r#"
1406name: FullFeaturedAgent
1407version: "0.4.0"
1408system_prompt: |
1409  You are a helpful assistant.
1410  User: {{ context.user.name }}
1411  Language: {{ context.user.language }}
1412llm:
1413  provider: openai
1414  model: gpt-4
1415context:
1416  user:
1417    type: runtime
1418    required: true
1419    default:
1420      name: "Guest"
1421      language: "en"
1422  time:
1423    type: builtin
1424    source: datetime
1425    refresh: per_turn
1426states:
1427  initial: greeting
1428  states:
1429    greeting:
1430      prompt: "Welcome to our service!"
1431      prompt_mode: append
1432      transitions:
1433        - to: support
1434          when: "user needs help"
1435          auto: true
1436          priority: 10
1437    support:
1438      prompt: "I'm here to help you."
1439      max_turns: 5
1440      timeout_to: escalation
1441      transitions:
1442        - to: closing
1443          when: "issue resolved"
1444          auto: true
1445    escalation:
1446      prompt: "Let me connect you with a human agent."
1447    closing:
1448      prompt: "Thank you for using our service!"
1449"#;
1450        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1451        assert!(builder.spec.is_some());
1452        let spec = builder.spec.as_ref().unwrap();
1453
1454        // Check context
1455        assert!(spec.has_context());
1456        assert_eq!(spec.context.len(), 2);
1457
1458        // Check states
1459        assert!(spec.has_states());
1460        let states = spec.states.as_ref().unwrap();
1461        assert_eq!(states.initial, "greeting");
1462        assert_eq!(states.states.len(), 4);
1463
1464        // Check greeting state details
1465        let greeting = states.states.get("greeting").unwrap();
1466        assert!(greeting.prompt.is_some());
1467        assert_eq!(greeting.transitions.len(), 1);
1468        assert_eq!(greeting.transitions[0].to, "support");
1469        assert!(greeting.transitions[0].auto);
1470
1471        // Check support state has timeout
1472        let support = states.states.get("support").unwrap();
1473        assert_eq!(support.max_turns, Some(5));
1474        assert_eq!(support.timeout_to, Some("escalation".to_string()));
1475    }
1476
1477    #[test]
1478    fn test_builder_from_yaml_with_hitl() {
1479        let yaml = r#"
1480name: HITLAgent
1481system_prompt: "You are a secure assistant."
1482llm:
1483  provider: openai
1484  model: gpt-4
1485hitl:
1486  default_timeout_seconds: 600
1487  on_timeout: reject
1488  tools:
1489    send_payment:
1490      require_approval: true
1491      approval_context:
1492        - amount
1493        - recipient
1494      approval_message: "Approve payment?"
1495    delete_record:
1496      require_approval: true
1497  conditions:
1498    - name: high_value
1499      when: "amount > 1000"
1500      require_approval: true
1501      approval_message: "High value transaction"
1502  states:
1503    escalation:
1504      on_enter: require_approval
1505      approval_message: "Escalate to human?"
1506"#;
1507        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1508        assert!(builder.spec.is_some());
1509        let spec = builder.spec.as_ref().unwrap();
1510
1511        assert!(spec.has_hitl());
1512        let hitl = spec.hitl.as_ref().unwrap();
1513        assert_eq!(hitl.default_timeout_seconds, 600);
1514        assert_eq!(hitl.tools.len(), 2);
1515        assert!(hitl.tools.get("send_payment").unwrap().require_approval);
1516        assert_eq!(hitl.conditions.len(), 1);
1517        assert_eq!(hitl.conditions[0].name, "high_value");
1518        assert_eq!(hitl.states.len(), 1);
1519    }
1520
1521    #[test]
1522    fn test_builder_from_yaml_with_compacting_memory() {
1523        let yaml = r#"
1524name: CompactingAgent
1525system_prompt: "You are a helpful assistant."
1526memory:
1527  type: compacting
1528  max_messages: 100
1529  max_recent_messages: 20
1530  compress_threshold: 15
1531  summarize_batch_size: 5
1532  summarizer_llm: router
1533llm:
1534  provider: openai
1535  model: gpt-4
1536"#;
1537        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1538        assert!(builder.spec.is_some());
1539        let spec = builder.spec.as_ref().unwrap();
1540
1541        assert!(spec.memory.is_compacting());
1542        assert_eq!(spec.memory.max_recent_messages, Some(20));
1543        assert_eq!(spec.memory.compress_threshold, Some(15));
1544        assert_eq!(spec.memory.summarize_batch_size, Some(5));
1545        assert_eq!(spec.memory.summarizer_llm, Some("router".to_string()));
1546
1547        let compacting_config = spec.memory.to_compacting_config();
1548        assert_eq!(compacting_config.max_recent_messages, 20);
1549        assert_eq!(compacting_config.compress_threshold, 15);
1550        assert_eq!(compacting_config.summarize_batch_size, 5);
1551    }
1552
1553    #[test]
1554    fn test_builder_from_yaml_with_token_budget() {
1555        let yaml = r#"
1556name: BudgetAgent
1557system_prompt: "You are a helpful assistant."
1558memory:
1559  type: compacting
1560  max_messages: 100
1561  token_budget:
1562    total: 8192
1563    allocation:
1564      summary: 2048
1565      recent_messages: 4096
1566      facts: 1024
1567    overflow_strategy: summarize_more
1568    warn_at_percent: 75
1569llm:
1570  provider: openai
1571  model: gpt-4
1572"#;
1573        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1574        assert!(builder.spec.is_some());
1575        let spec = builder.spec.as_ref().unwrap();
1576
1577        assert!(spec.memory.token_budget.is_some());
1578        let budget = spec.memory.token_budget.as_ref().unwrap();
1579        assert_eq!(budget.total, 8192);
1580        assert_eq!(budget.allocation.summary, 2048);
1581        assert_eq!(budget.allocation.recent_messages, 4096);
1582        assert_eq!(budget.allocation.facts, 1024);
1583        assert_eq!(budget.warn_at_percent, 75);
1584    }
1585
1586    #[test]
1587    fn test_builder_from_yaml_with_overflow_strategies() {
1588        use ai_agents_memory::OverflowStrategy;
1589
1590        let yaml = r#"
1591name: TruncateAgent
1592system_prompt: "You are helpful."
1593memory:
1594  type: compacting
1595  token_budget:
1596    total: 4096
1597    overflow_strategy: truncate_oldest
1598llm:
1599  provider: openai
1600  model: gpt-4
1601"#;
1602        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1603        let budget = builder
1604            .spec
1605            .as_ref()
1606            .unwrap()
1607            .memory
1608            .token_budget
1609            .as_ref()
1610            .unwrap();
1611        assert_eq!(budget.overflow_strategy, OverflowStrategy::TruncateOldest);
1612
1613        let yaml = r#"
1614name: ErrorAgent
1615system_prompt: "You are helpful."
1616memory:
1617  type: compacting
1618  token_budget:
1619    total: 4096
1620    overflow_strategy: error
1621llm:
1622  provider: openai
1623  model: gpt-4
1624"#;
1625        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1626        let budget = builder
1627            .spec
1628            .as_ref()
1629            .unwrap()
1630            .memory
1631            .token_budget
1632            .as_ref()
1633            .unwrap();
1634        assert_eq!(budget.overflow_strategy, OverflowStrategy::Error);
1635    }
1636
1637    #[test]
1638    fn test_builder_from_yaml_with_storage_file() {
1639        let yaml = r#"
1640name: PersistentAgent
1641system_prompt: "You are helpful."
1642llm:
1643  provider: openai
1644  model: gpt-4
1645storage:
1646  type: file
1647  path: "./data/sessions"
1648"#;
1649        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1650        let spec = builder.spec.as_ref().unwrap();
1651        assert!(spec.has_storage());
1652        assert!(spec.storage.is_file());
1653        assert_eq!(spec.storage.get_path(), Some("./data/sessions"));
1654    }
1655
1656    #[test]
1657    fn test_builder_from_yaml_with_storage_sqlite() {
1658        let yaml = r#"
1659name: PersistentAgent
1660system_prompt: "You are helpful."
1661llm:
1662  provider: openai
1663  model: gpt-4
1664storage:
1665  type: sqlite
1666  path: "./data/sessions.db"
1667"#;
1668        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1669        let spec = builder.spec.as_ref().unwrap();
1670        assert!(spec.has_storage());
1671        assert!(spec.storage.is_sqlite());
1672    }
1673
1674    #[test]
1675    fn test_builder_from_yaml_with_storage_redis() {
1676        let yaml = r#"
1677name: PersistentAgent
1678system_prompt: "You are helpful."
1679llm:
1680  provider: openai
1681  model: gpt-4
1682storage:
1683  type: redis
1684  url: "redis://localhost:6379"
1685  prefix: "myagent:"
1686  ttl_seconds: 86400
1687"#;
1688        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1689        let spec = builder.spec.as_ref().unwrap();
1690        assert!(spec.has_storage());
1691        assert!(spec.storage.is_redis());
1692        assert_eq!(spec.storage.get_prefix(), "myagent:");
1693        assert_eq!(spec.storage.get_ttl(), Some(86400));
1694    }
1695
1696    #[test]
1697    fn test_builder_no_storage_by_default() {
1698        let yaml = r#"
1699name: SimpleAgent
1700system_prompt: "You are helpful."
1701llm:
1702  provider: openai
1703  model: gpt-4
1704"#;
1705        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1706        let spec = builder.spec.as_ref().unwrap();
1707        assert!(!spec.has_storage());
1708    }
1709
1710    #[test]
1711    fn test_build_fails_on_missing_declared_tool() {
1712        use ai_agents_llm::mock::MockLLMProvider;
1713
1714        let yaml = r#"
1715name: ToolAgent
1716system_prompt: "You are helpful."
1717llm:
1718  provider: openai
1719  model: gpt-4
1720tools:
1721  - name: lookup_order
1722  - name: calculator
1723"#;
1724        let llm = Arc::new(MockLLMProvider::new("test"));
1725        let result = AgentBuilder::from_yaml(yaml)
1726            .unwrap()
1727            .llm(llm)
1728            .auto_configure_features()
1729            .unwrap()
1730            // NOT registering lookup_order — should fail
1731            .build();
1732
1733        assert!(result.is_err());
1734        let err = result.unwrap_err().to_string();
1735        assert!(
1736            err.contains("lookup_order"),
1737            "error should name the missing tool: {}",
1738            err
1739        );
1740        // calculator is built-in, so it should NOT appear in the error
1741        assert!(
1742            !err.contains("calculator"),
1743            "calculator is registered, should not be missing: {}",
1744            err
1745        );
1746    }
1747
1748    #[test]
1749    fn test_build_succeeds_when_declared_tool_is_registered() {
1750        use ai_agents_core::Tool;
1751        use ai_agents_llm::mock::MockLLMProvider;
1752
1753        struct FakeTool;
1754        #[async_trait::async_trait]
1755        impl Tool for FakeTool {
1756            fn id(&self) -> &str {
1757                "lookup_order"
1758            }
1759            fn name(&self) -> &str {
1760                "Order Lookup"
1761            }
1762            fn description(&self) -> &str {
1763                "Look up an order"
1764            }
1765            fn input_schema(&self) -> serde_json::Value {
1766                serde_json::json!({})
1767            }
1768            async fn execute(&self, _args: serde_json::Value) -> ai_agents_core::ToolResult {
1769                ai_agents_core::ToolResult::ok("ok")
1770            }
1771        }
1772
1773        let yaml = r#"
1774name: ToolAgent
1775system_prompt: "You are helpful."
1776llm:
1777  provider: openai
1778  model: gpt-4
1779tools:
1780  - name: lookup_order
1781  - name: calculator
1782"#;
1783        let llm = Arc::new(MockLLMProvider::new("test"));
1784        let result = AgentBuilder::from_yaml(yaml)
1785            .unwrap()
1786            .llm(llm)
1787            .auto_configure_features()
1788            .unwrap()
1789            .tool(Arc::new(FakeTool))
1790            .build();
1791
1792        assert!(
1793            result.is_ok(),
1794            "build should succeed when all declared tools are registered: {:?}",
1795            result.err()
1796        );
1797    }
1798
1799    #[test]
1800    fn test_spawner_config_deserializes_shared_storage() {
1801        let yaml = r#"
1802name: TestAgent
1803system_prompt: "Test"
1804llm:
1805  provider: openai
1806  model: gpt-4
1807spawner:
1808  shared_llms: true
1809  shared_storage:
1810    type: sqlite
1811    path: ./data/test.db
1812  max_agents: 5
1813"#;
1814        let builder = AgentBuilder::from_yaml(yaml).unwrap();
1815        let spec = builder.spec.as_ref().unwrap();
1816        let sc = spec.spawner.as_ref().unwrap();
1817        assert!(sc.shared_storage.is_some());
1818        assert!(sc.shared_storage.as_ref().unwrap().is_sqlite());
1819    }
1820
1821    #[test]
1822    fn test_build_succeeds_with_no_tools_declared() {
1823        use ai_agents_llm::mock::MockLLMProvider;
1824
1825        let yaml = r#"
1826name: SimpleAgent
1827system_prompt: "You are helpful."
1828llm:
1829  provider: openai
1830  model: gpt-4
1831"#;
1832        let llm = Arc::new(MockLLMProvider::new("test"));
1833        let result = AgentBuilder::from_yaml(yaml)
1834            .unwrap()
1835            .llm(llm)
1836            .auto_configure_features()
1837            .unwrap()
1838            .build();
1839
1840        assert!(
1841            result.is_ok(),
1842            "no tools: section means no validation needed: {:?}",
1843            result.err()
1844        );
1845    }
1846}