Skip to main content

awaken_runtime/
builder.rs

1//! Fluent builder API for constructing `AgentRuntime`.
2
3use std::sync::Arc;
4
5use awaken_contract::StateError;
6use awaken_contract::contract::executor::LlmExecutor;
7use awaken_contract::contract::storage::ThreadRunStore;
8use awaken_contract::contract::tool::Tool;
9use awaken_contract::registry_spec::AgentSpec;
10
11#[cfg(feature = "a2a")]
12use crate::backend::ExecutionBackendFactory;
13use crate::plugins::Plugin;
14#[cfg(feature = "a2a")]
15use crate::registry::BackendRegistry;
16#[cfg(feature = "a2a")]
17use crate::registry::composite::{CompositeAgentSpecRegistry, RemoteAgentSource};
18#[cfg(feature = "a2a")]
19use crate::registry::memory::MapBackendRegistry;
20use crate::registry::memory::{
21    MapAgentSpecRegistry, MapModelRegistry, MapPluginSource, MapProviderRegistry, MapToolRegistry,
22};
23use crate::registry::snapshot::RegistryHandle;
24use crate::registry::traits::{AgentSpecRegistry, ModelBinding, RegistrySet};
25use crate::runtime::AgentRuntime;
26
27/// Error returned when the builder cannot construct the runtime.
28#[derive(Debug, thiserror::Error)]
29pub enum BuildError {
30    #[error("state error: {0}")]
31    State(#[from] StateError),
32    #[error("agent registry conflict: {0}")]
33    AgentRegistryConflict(String),
34    #[error("tool registry conflict: {0}")]
35    ToolRegistryConflict(String),
36    #[error("model registry conflict: {0}")]
37    ModelRegistryConflict(String),
38    #[error("provider registry conflict: {0}")]
39    ProviderRegistryConflict(String),
40    #[error("plugin registry conflict: {0}")]
41    PluginRegistryConflict(String),
42    #[cfg(feature = "a2a")]
43    #[error("backend registry conflict: {0}")]
44    BackendRegistryConflict(String),
45    #[error("agent validation failed: {0}")]
46    ValidationFailed(String),
47    #[cfg(feature = "a2a")]
48    #[error("discovery failed: {0}")]
49    DiscoveryFailed(#[from] crate::registry::composite::DiscoveryError),
50}
51
52/// Fluent API for constructing an `AgentRuntime`.
53///
54/// Collects agent specs, tools, plugins, models, providers, and optionally
55/// a store, then builds the fully resolved runtime.
56pub struct AgentRuntimeBuilder {
57    agents: MapAgentSpecRegistry,
58    tools: MapToolRegistry,
59    models: MapModelRegistry,
60    providers: MapProviderRegistry,
61    plugins: MapPluginSource,
62    #[cfg(feature = "a2a")]
63    backends: MapBackendRegistry,
64    thread_run_store: Option<Arc<dyn ThreadRunStore>>,
65    profile_store: Option<Arc<dyn awaken_contract::contract::profile_store::ProfileStore>>,
66    errors: Vec<BuildError>,
67    #[cfg(feature = "a2a")]
68    remote_sources: Vec<RemoteAgentSource>,
69}
70
71impl AgentRuntimeBuilder {
72    pub fn new() -> Self {
73        Self {
74            agents: MapAgentSpecRegistry::new(),
75            tools: MapToolRegistry::new(),
76            models: MapModelRegistry::new(),
77            providers: MapProviderRegistry::new(),
78            plugins: MapPluginSource::new(),
79            #[cfg(feature = "a2a")]
80            backends: MapBackendRegistry::with_default_remote_backends(),
81            thread_run_store: None,
82            profile_store: None,
83            errors: Vec::new(),
84            #[cfg(feature = "a2a")]
85            remote_sources: Vec::new(),
86        }
87    }
88
89    /// Register an agent spec.
90    pub fn with_agent_spec(mut self, spec: AgentSpec) -> Self {
91        if let Err(e) = self.agents.register_spec(spec) {
92            self.errors.push(e);
93        }
94        self
95    }
96
97    /// Register multiple agent specs.
98    pub fn with_agent_specs(mut self, specs: impl IntoIterator<Item = AgentSpec>) -> Self {
99        for spec in specs {
100            if let Err(e) = self.agents.register_spec(spec) {
101                self.errors.push(e);
102            }
103        }
104        self
105    }
106
107    /// Register a tool by ID.
108    pub fn with_tool(mut self, id: impl Into<String>, tool: Arc<dyn Tool>) -> Self {
109        if let Err(e) = self.tools.register_tool(id, tool) {
110            self.errors.push(e);
111        }
112        self
113    }
114
115    /// Register a plugin by ID.
116    pub fn with_plugin(mut self, id: impl Into<String>, plugin: Arc<dyn Plugin>) -> Self {
117        if let Err(e) = self.plugins.register_plugin(id, plugin) {
118            self.errors.push(e);
119        }
120        self
121    }
122
123    /// Register a model binding by ID.
124    pub fn with_model_binding(mut self, id: impl Into<String>, binding: ModelBinding) -> Self {
125        if let Err(e) = self.models.register_model(id, binding) {
126            self.errors.push(e);
127        }
128        self
129    }
130
131    /// Register a provider (LLM executor) by ID.
132    pub fn with_provider(mut self, id: impl Into<String>, executor: Arc<dyn LlmExecutor>) -> Self {
133        if let Err(e) = self.providers.register_provider(id, executor) {
134            self.errors.push(e);
135        }
136        self
137    }
138
139    /// Set the thread run store for persistence.
140    pub fn with_thread_run_store(mut self, store: Arc<dyn ThreadRunStore>) -> Self {
141        self.thread_run_store = Some(store);
142        self
143    }
144
145    /// Set the profile store for cross-run key-value persistence.
146    pub fn with_profile_store(
147        mut self,
148        store: Arc<dyn awaken_contract::contract::profile_store::ProfileStore>,
149    ) -> Self {
150        self.profile_store = Some(store);
151        self
152    }
153
154    /// Add a named remote A2A agent source for discovery.
155    ///
156    /// When remote sources are configured, the builder creates a
157    /// [`CompositeAgentSpecRegistry`] that combines local agents with
158    /// agents discovered from remote A2A endpoints. The `name` is used
159    /// for namespaced agent lookup (e.g., `"cloud/translator"`).
160    #[cfg(feature = "a2a")]
161    pub fn with_remote_agents(
162        mut self,
163        name: impl Into<String>,
164        base_url: impl Into<String>,
165        bearer_token: Option<String>,
166    ) -> Self {
167        self.remote_sources.push(RemoteAgentSource {
168            name: name.into(),
169            base_url: base_url.into(),
170            bearer_token,
171        });
172        self
173    }
174
175    /// Register a remote delegate backend factory by its backend kind.
176    #[cfg(feature = "a2a")]
177    pub fn with_agent_backend_factory(mut self, factory: Arc<dyn ExecutionBackendFactory>) -> Self {
178        if let Err(e) = self.backends.register_backend_factory(factory) {
179            self.errors.push(e);
180        }
181        self
182    }
183
184    /// Build the `AgentRuntime` and validate all registered agents can
185    /// resolve successfully.
186    ///
187    /// Performs a dry-run resolve for every registered agent, catching
188    /// configuration errors (missing models, providers, plugins) at build time.
189    /// Use [`build_unchecked()`](Self::build_unchecked) to skip validation.
190    pub fn build(self) -> Result<AgentRuntime, BuildError> {
191        let runtime = self.build_unchecked()?;
192        let resolver = runtime.resolver();
193        #[cfg(feature = "a2a")]
194        let registries = runtime.registry_set();
195        let mut errors = Vec::new();
196        for agent_id in resolver.agent_ids() {
197            #[cfg(feature = "a2a")]
198            {
199                if let Some(spec) = registries
200                    .as_ref()
201                    .and_then(|set| set.agents.get_agent(&agent_id))
202                    && let Some(endpoint) = &spec.endpoint
203                {
204                    let Some(factory) = registries
205                        .as_ref()
206                        .and_then(|set| set.backends.get_backend_factory(&endpoint.backend))
207                    else {
208                        errors.push(format!(
209                            "{agent_id}: unsupported remote backend '{}'",
210                            endpoint.backend
211                        ));
212                        continue;
213                    };
214                    if let Err(error) = factory.validate(endpoint) {
215                        errors.push(format!("{agent_id}: {error}"));
216                    }
217                    continue;
218                }
219            }
220
221            if let Err(e) = resolver.resolve(&agent_id) {
222                errors.push(format!("{agent_id}: {e}"));
223            }
224        }
225        if !errors.is_empty() {
226            return Err(BuildError::ValidationFailed(errors.join("; ")));
227        }
228        Ok(runtime)
229    }
230
231    /// Build the `AgentRuntime` from the accumulated configuration,
232    /// skipping agent validation.
233    ///
234    /// Prefer [`build()`](Self::build) which validates all registered agents
235    /// can resolve successfully at build time.
236    pub fn build_unchecked(mut self) -> Result<AgentRuntime, BuildError> {
237        if !self.errors.is_empty() {
238            return Err(self.errors.remove(0));
239        }
240
241        #[cfg(feature = "a2a")]
242        let (agents, composite_registry): (Arc<dyn AgentSpecRegistry>, _) =
243            if self.remote_sources.is_empty() {
244                (Arc::new(self.agents), None)
245            } else {
246                let mut composite = CompositeAgentSpecRegistry::new(Arc::new(self.agents));
247                for source in self.remote_sources {
248                    composite.add_remote(source);
249                }
250                let arc = Arc::new(composite);
251                (Arc::clone(&arc) as Arc<dyn AgentSpecRegistry>, Some(arc))
252            };
253        #[cfg(not(feature = "a2a"))]
254        let agents: Arc<dyn AgentSpecRegistry> = Arc::new(self.agents);
255
256        let registry_set = RegistrySet {
257            agents,
258            tools: Arc::new(self.tools),
259            models: Arc::new(self.models),
260            providers: Arc::new(self.providers),
261            plugins: Arc::new(self.plugins),
262            #[cfg(feature = "a2a")]
263            backends: Arc::new(self.backends) as Arc<dyn BackendRegistry>,
264        };
265
266        let registry_handle = RegistryHandle::new(registry_set.clone());
267        let resolver: Arc<dyn crate::registry::ExecutionResolver> = Arc::new(
268            crate::registry::resolve::DynamicRegistryResolver::new(registry_handle.clone()),
269        );
270
271        let mut runtime = AgentRuntime::new_with_execution_resolver(resolver)
272            .with_registry_handle(registry_handle);
273
274        #[cfg(feature = "a2a")]
275        if let Some(composite) = composite_registry {
276            runtime = runtime.with_composite_registry(composite);
277        }
278
279        if let Some(store) = self.thread_run_store {
280            runtime = runtime.with_thread_run_store(store);
281        }
282
283        if let Some(store) = self.profile_store {
284            runtime = runtime.with_profile_store(store);
285        }
286
287        Ok(runtime)
288    }
289
290    /// Build and initialize (async). Discovers remote agents after build.
291    #[cfg(feature = "a2a")]
292    pub async fn build_and_discover(self) -> Result<AgentRuntime, BuildError> {
293        let runtime = self.build_unchecked()?;
294        if let Some(composite) = runtime.composite_registry() {
295            composite.discover().await?;
296        }
297        Ok(runtime)
298    }
299}
300
301impl Default for AgentRuntimeBuilder {
302    fn default() -> Self {
303        Self::new()
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use async_trait::async_trait;
311    use awaken_contract::contract::executor::{InferenceExecutionError, InferenceRequest};
312    use awaken_contract::contract::inference::{StopReason, StreamResult, TokenUsage};
313    #[cfg(feature = "a2a")]
314    use awaken_contract::contract::lifecycle::TerminationReason;
315    use awaken_contract::contract::tool::{
316        ToolCallContext, ToolDescriptor, ToolError, ToolOutput, ToolResult,
317    };
318    #[cfg(feature = "a2a")]
319    use awaken_contract::registry_spec::RemoteEndpoint;
320    use serde_json::Value;
321    #[cfg(feature = "a2a")]
322    use std::sync::atomic::{AtomicUsize, Ordering};
323
324    use crate::registry::memory::{
325        MapAgentSpecRegistry, MapModelRegistry, MapPluginSource, MapProviderRegistry,
326        MapToolRegistry,
327    };
328
329    struct MockTool {
330        id: String,
331    }
332
333    #[async_trait]
334    impl Tool for MockTool {
335        fn descriptor(&self) -> ToolDescriptor {
336            ToolDescriptor::new(&self.id, &self.id, "mock tool")
337        }
338
339        async fn execute(
340            &self,
341            _args: Value,
342            _ctx: &ToolCallContext,
343        ) -> Result<ToolOutput, ToolError> {
344            Ok(ToolResult::success(&self.id, Value::Null).into())
345        }
346    }
347
348    struct MockExecutor;
349
350    #[async_trait]
351    impl LlmExecutor for MockExecutor {
352        async fn execute(
353            &self,
354            _request: InferenceRequest,
355        ) -> Result<StreamResult, InferenceExecutionError> {
356            Ok(StreamResult {
357                content: vec![],
358                tool_calls: vec![],
359                usage: Some(TokenUsage::default()),
360                stop_reason: Some(StopReason::EndTurn),
361                has_incomplete_tool_calls: false,
362            })
363        }
364
365        fn name(&self) -> &str {
366            "mock"
367        }
368    }
369
370    #[cfg(feature = "a2a")]
371    struct NoopRemoteBackend;
372
373    #[cfg(feature = "a2a")]
374    #[async_trait]
375    impl crate::backend::ExecutionBackend for NoopRemoteBackend {
376        async fn execute_root(
377            &self,
378            request: crate::backend::BackendRootRunRequest<'_>,
379        ) -> Result<crate::backend::BackendRunResult, crate::backend::ExecutionBackendError>
380        {
381            Ok(crate::backend::BackendRunResult {
382                agent_id: request.agent_id.to_string(),
383                status: crate::backend::BackendRunStatus::Completed,
384                termination: TerminationReason::NaturalEnd,
385                status_reason: None,
386                response: None,
387                output: crate::backend::BackendRunOutput::default(),
388                steps: 0,
389                run_id: None,
390                inbox: None,
391                state: None,
392            })
393        }
394    }
395
396    #[cfg(feature = "a2a")]
397    struct CountingValidationBackendFactory {
398        validate_count: Arc<AtomicUsize>,
399        build_count: Arc<AtomicUsize>,
400    }
401
402    #[cfg(feature = "a2a")]
403    impl crate::backend::ExecutionBackendFactory for CountingValidationBackendFactory {
404        fn backend(&self) -> &str {
405            "counting-remote"
406        }
407
408        fn validate(
409            &self,
410            endpoint: &RemoteEndpoint,
411        ) -> Result<(), crate::backend::ExecutionBackendFactoryError> {
412            self.validate_count.fetch_add(1, Ordering::SeqCst);
413            if endpoint.base_url.trim().is_empty() {
414                return Err(crate::backend::ExecutionBackendFactoryError::InvalidConfig(
415                    "empty base_url".into(),
416                ));
417            }
418            Ok(())
419        }
420
421        fn build(
422            &self,
423            endpoint: &RemoteEndpoint,
424        ) -> Result<
425            Arc<dyn crate::backend::ExecutionBackend>,
426            crate::backend::ExecutionBackendFactoryError,
427        > {
428            self.build_count.fetch_add(1, Ordering::SeqCst);
429            if endpoint.backend != self.backend() {
430                return Err(crate::backend::ExecutionBackendFactoryError::InvalidConfig(
431                    format!("unexpected backend '{}'", endpoint.backend),
432                ));
433            }
434            Ok(Arc::new(NoopRemoteBackend))
435        }
436    }
437
438    fn make_registry_set(agent_id: &str, model_id: &str, upstream_model: &str) -> RegistrySet {
439        let mut agents = MapAgentSpecRegistry::new();
440        agents
441            .register_spec(AgentSpec {
442                id: agent_id.into(),
443                model_id: model_id.into(),
444                system_prompt: format!("system-{agent_id}"),
445                ..Default::default()
446            })
447            .expect("register test agent");
448
449        let mut models = MapModelRegistry::new();
450        models
451            .register_model(
452                model_id,
453                ModelBinding {
454                    provider_id: "mock".into(),
455                    upstream_model: upstream_model.into(),
456                },
457            )
458            .expect("register test model");
459
460        let mut providers = MapProviderRegistry::new();
461        providers
462            .register_provider("mock", Arc::new(MockExecutor))
463            .expect("register test provider");
464
465        RegistrySet {
466            agents: Arc::new(agents),
467            tools: Arc::new(MapToolRegistry::new()),
468            models: Arc::new(models),
469            providers: Arc::new(providers),
470            plugins: Arc::new(MapPluginSource::new()),
471            backends: Arc::new(MapBackendRegistry::new()),
472        }
473    }
474
475    #[test]
476    fn builder_creates_runtime() {
477        let spec = AgentSpec {
478            id: "test-agent".into(),
479            model_id: "test-model".into(),
480            system_prompt: "You are helpful.".into(),
481            ..Default::default()
482        };
483
484        let runtime = AgentRuntimeBuilder::new()
485            .with_agent_spec(spec)
486            .with_tool("echo", Arc::new(MockTool { id: "echo".into() }))
487            .with_model_binding(
488                "test-model",
489                ModelBinding {
490                    provider_id: "mock".into(),
491                    upstream_model: "mock-model".into(),
492                },
493            )
494            .with_provider("mock", Arc::new(MockExecutor))
495            .build();
496
497        assert!(runtime.is_ok());
498    }
499
500    #[test]
501    fn builder_default_creates_empty() {
502        let builder = AgentRuntimeBuilder::default();
503        // Cannot resolve any agent but should build
504        let runtime = builder.build();
505        assert!(runtime.is_ok());
506    }
507
508    #[test]
509    fn builder_with_multiple_agents() {
510        let spec1 = AgentSpec {
511            id: "agent-1".into(),
512            model_id: "m".into(),
513            system_prompt: "sys".into(),
514            ..Default::default()
515        };
516        let spec2 = AgentSpec {
517            id: "agent-2".into(),
518            model_id: "m".into(),
519            system_prompt: "sys".into(),
520            ..Default::default()
521        };
522
523        let runtime = AgentRuntimeBuilder::new()
524            .with_agent_specs(vec![spec1, spec2])
525            .with_model_binding(
526                "m",
527                ModelBinding {
528                    provider_id: "p".into(),
529                    upstream_model: "n".into(),
530                },
531            )
532            .with_provider("p", Arc::new(MockExecutor))
533            .build()
534            .unwrap();
535
536        // Both agents should be resolvable
537        assert!(runtime.resolver().resolve("agent-1").is_ok());
538        assert!(runtime.resolver().resolve("agent-2").is_ok());
539    }
540
541    #[test]
542    fn builder_resolver_returns_correct_config() {
543        let spec = AgentSpec {
544            id: "my-agent".into(),
545            model_id: "test-model".into(),
546            system_prompt: "Be helpful.".into(),
547            max_rounds: 10,
548            ..Default::default()
549        };
550
551        let runtime = AgentRuntimeBuilder::new()
552            .with_agent_spec(spec)
553            .with_tool(
554                "search",
555                Arc::new(MockTool {
556                    id: "search".into(),
557                }),
558            )
559            .with_model_binding(
560                "test-model",
561                ModelBinding {
562                    provider_id: "mock".into(),
563                    upstream_model: "claude-test".into(),
564                },
565            )
566            .with_provider("mock", Arc::new(MockExecutor))
567            .build()
568            .unwrap();
569
570        let resolved = runtime.resolver().resolve("my-agent").unwrap();
571        assert_eq!(resolved.id(), "my-agent");
572        assert_eq!(resolved.upstream_model, "claude-test");
573        assert_eq!(resolved.system_prompt(), "Be helpful.");
574        assert_eq!(resolved.max_rounds(), 10);
575        assert!(resolved.tools.contains_key("search"));
576    }
577
578    #[test]
579    fn builder_missing_agent_errors() {
580        let runtime = AgentRuntimeBuilder::new()
581            .with_model_binding(
582                "m",
583                ModelBinding {
584                    provider_id: "p".into(),
585                    upstream_model: "n".into(),
586                },
587            )
588            .with_provider("p", Arc::new(MockExecutor))
589            .build()
590            .unwrap();
591
592        let err = runtime.resolver().resolve("nonexistent");
593        assert!(err.is_err());
594    }
595
596    // -----------------------------------------------------------------------
597    // Migrated from uncarve: additional builder tests
598    // -----------------------------------------------------------------------
599
600    #[test]
601    fn builder_with_plugin() {
602        use crate::plugins::{Plugin, PluginDescriptor, PluginRegistrar};
603
604        struct TestPlugin;
605        impl Plugin for TestPlugin {
606            fn descriptor(&self) -> PluginDescriptor {
607                PluginDescriptor {
608                    name: "test-builder-plugin",
609                }
610            }
611            fn register(
612                &self,
613                _registrar: &mut PluginRegistrar,
614            ) -> Result<(), awaken_contract::StateError> {
615                Ok(())
616            }
617        }
618
619        let runtime = AgentRuntimeBuilder::new()
620            .with_plugin("test-builder-plugin", Arc::new(TestPlugin))
621            .build()
622            .unwrap();
623        let _ = runtime;
624    }
625
626    #[test]
627    fn builder_chained_tools_all_registered() {
628        let spec = AgentSpec {
629            id: "agent".into(),
630            model_id: "m".into(),
631            system_prompt: "sys".into(),
632            ..Default::default()
633        };
634
635        let runtime = AgentRuntimeBuilder::new()
636            .with_agent_spec(spec)
637            .with_tool("t1", Arc::new(MockTool { id: "t1".into() }))
638            .with_tool("t2", Arc::new(MockTool { id: "t2".into() }))
639            .with_tool("t3", Arc::new(MockTool { id: "t3".into() }))
640            .with_model_binding(
641                "m",
642                ModelBinding {
643                    provider_id: "p".into(),
644                    upstream_model: "n".into(),
645                },
646            )
647            .with_provider("p", Arc::new(MockExecutor))
648            .build()
649            .unwrap();
650
651        let resolved = runtime.resolver().resolve("agent").unwrap();
652        assert!(resolved.tools.contains_key("t1"));
653        assert!(resolved.tools.contains_key("t2"));
654        assert!(resolved.tools.contains_key("t3"));
655    }
656
657    #[test]
658    fn build_catches_missing_model() {
659        let spec = AgentSpec {
660            id: "bad-agent".into(),
661            model_id: "nonexistent-model".into(),
662            system_prompt: "sys".into(),
663            ..Default::default()
664        };
665
666        let result = AgentRuntimeBuilder::new().with_agent_spec(spec).build();
667
668        let err = match result {
669            Err(e) => e.to_string(),
670            Ok(_) => panic!("expected build to fail for missing model"),
671        };
672        assert!(
673            err.contains("bad-agent"),
674            "error should mention the agent ID: {err}"
675        );
676    }
677
678    #[test]
679    fn build_succeeds_with_valid_config() {
680        let spec = AgentSpec {
681            id: "good-agent".into(),
682            model_id: "m".into(),
683            system_prompt: "sys".into(),
684            ..Default::default()
685        };
686
687        let result = AgentRuntimeBuilder::new()
688            .with_agent_spec(spec)
689            .with_model_binding(
690                "m",
691                ModelBinding {
692                    provider_id: "p".into(),
693                    upstream_model: "n".into(),
694                },
695            )
696            .with_provider("p", Arc::new(MockExecutor))
697            .build();
698
699        assert!(result.is_ok());
700    }
701
702    #[test]
703    fn builder_runtime_starts_with_registry_version_one() {
704        let runtime = AgentRuntimeBuilder::new()
705            .with_agent_spec(AgentSpec {
706                id: "versioned-agent".into(),
707                model_id: "m".into(),
708                system_prompt: "sys".into(),
709                ..Default::default()
710            })
711            .with_model_binding(
712                "m",
713                ModelBinding {
714                    provider_id: "mock".into(),
715                    upstream_model: "model-v1".into(),
716                },
717            )
718            .with_provider("mock", Arc::new(MockExecutor))
719            .build()
720            .unwrap();
721
722        assert_eq!(runtime.registry_version(), Some(1));
723        assert!(runtime.registry_handle().is_some());
724    }
725
726    #[test]
727    fn replacing_registry_set_updates_dynamic_resolver() {
728        let runtime = AgentRuntimeBuilder::new()
729            .with_agent_spec(AgentSpec {
730                id: "agent-v1".into(),
731                model_id: "m".into(),
732                system_prompt: "sys".into(),
733                ..Default::default()
734            })
735            .with_model_binding(
736                "m",
737                ModelBinding {
738                    provider_id: "mock".into(),
739                    upstream_model: "model-v1".into(),
740                },
741            )
742            .with_provider("mock", Arc::new(MockExecutor))
743            .build()
744            .unwrap();
745
746        assert!(runtime.resolver().resolve("agent-v1").is_ok());
747        assert!(runtime.resolver().resolve("agent-v2").is_err());
748
749        let version = runtime
750            .replace_registry_set(make_registry_set("agent-v2", "m2", "model-v2"))
751            .expect("builder runtimes should expose a registry handle");
752
753        assert_eq!(version, 2);
754        assert_eq!(runtime.registry_version(), Some(2));
755        assert!(runtime.resolver().resolve("agent-v1").is_err());
756
757        let resolved = runtime.resolver().resolve("agent-v2").unwrap();
758        assert_eq!(resolved.id(), "agent-v2");
759        assert_eq!(resolved.upstream_model, "model-v2");
760    }
761
762    #[test]
763    fn builder_model_binding_provider_name() {
764        let spec = AgentSpec {
765            id: "agent".into(),
766            model_id: "gpt-4".into(),
767            system_prompt: "sys".into(),
768            ..Default::default()
769        };
770
771        let runtime = AgentRuntimeBuilder::new()
772            .with_agent_spec(spec)
773            .with_model_binding(
774                "gpt-4",
775                ModelBinding {
776                    provider_id: "openai".into(),
777                    upstream_model: "gpt-4-turbo".into(),
778                },
779            )
780            .with_provider("openai", Arc::new(MockExecutor))
781            .build()
782            .unwrap();
783
784        let resolved = runtime.resolver().resolve("agent").unwrap();
785        // The model ID should resolve to the upstream model name
786        assert_eq!(resolved.upstream_model, "gpt-4-turbo");
787    }
788
789    #[test]
790    fn builder_with_profile_store() {
791        use awaken_contract::contract::profile_store::{
792            ProfileEntry, ProfileOwner as POwner, ProfileStore,
793        };
794        use awaken_contract::contract::storage::StorageError;
795
796        struct NoOpProfileStore;
797
798        #[async_trait]
799        impl ProfileStore for NoOpProfileStore {
800            async fn get(
801                &self,
802                _owner: &POwner,
803                _key: &str,
804            ) -> Result<Option<ProfileEntry>, StorageError> {
805                Ok(None)
806            }
807            async fn set(
808                &self,
809                _owner: &POwner,
810                _key: &str,
811                _value: Value,
812            ) -> Result<(), StorageError> {
813                Ok(())
814            }
815            async fn delete(&self, _owner: &POwner, _key: &str) -> Result<(), StorageError> {
816                Ok(())
817            }
818            async fn list(&self, _owner: &POwner) -> Result<Vec<ProfileEntry>, StorageError> {
819                Ok(vec![])
820            }
821            async fn clear_owner(&self, _owner: &POwner) -> Result<(), StorageError> {
822                Ok(())
823            }
824        }
825
826        let runtime = AgentRuntimeBuilder::new()
827            .with_profile_store(Arc::new(NoOpProfileStore))
828            .build()
829            .unwrap();
830        assert!(runtime.profile_store.is_some());
831    }
832
833    #[cfg(feature = "a2a")]
834    #[test]
835    fn build_allows_endpoint_agents_when_backend_factory_exists() {
836        let validate_count = Arc::new(AtomicUsize::new(0));
837        let build_count = Arc::new(AtomicUsize::new(0));
838        let runtime = AgentRuntimeBuilder::new()
839            .with_agent_spec(
840                AgentSpec::new("remote-agent")
841                    .with_model_id("remote-model")
842                    .with_system_prompt("remote")
843                    .with_endpoint(RemoteEndpoint {
844                        backend: "counting-remote".into(),
845                        base_url: "https://remote.example.com".into(),
846                        ..Default::default()
847                    }),
848            )
849            .with_agent_backend_factory(Arc::new(CountingValidationBackendFactory {
850                validate_count: validate_count.clone(),
851                build_count: build_count.clone(),
852            }))
853            .build()
854            .expect("endpoint agents should validate through backend factory");
855
856        let spec = runtime
857            .registry_set()
858            .and_then(|set| set.agents.get_agent("remote-agent"))
859            .expect("remote agent should remain registered");
860        assert!(spec.endpoint.is_some());
861        assert_eq!(validate_count.load(Ordering::SeqCst), 1);
862        assert_eq!(build_count.load(Ordering::SeqCst), 0);
863    }
864
865    #[test]
866    fn duplicate_agent_spec_errors_at_build() {
867        let spec = AgentSpec {
868            id: "dup-agent".into(),
869            model_id: "m".into(),
870            system_prompt: "sys".into(),
871            ..Default::default()
872        };
873
874        let result = AgentRuntimeBuilder::new()
875            .with_agent_spec(spec.clone())
876            .with_agent_spec(spec)
877            .with_model_binding(
878                "m",
879                ModelBinding {
880                    provider_id: "p".into(),
881                    upstream_model: "n".into(),
882                },
883            )
884            .with_provider("p", Arc::new(MockExecutor))
885            .build();
886
887        match result {
888            Err(e) => {
889                let err = e.to_string();
890                assert!(
891                    err.contains("dup-agent"),
892                    "error should mention the duplicate agent ID: {err}"
893                );
894            }
895            Ok(_) => panic!("expected build to fail for duplicate agent"),
896        }
897    }
898
899    #[test]
900    fn duplicate_tool_errors_at_build() {
901        let result = AgentRuntimeBuilder::new()
902            .with_tool(
903                "dup-tool",
904                Arc::new(MockTool {
905                    id: "dup-tool".into(),
906                }),
907            )
908            .with_tool(
909                "dup-tool",
910                Arc::new(MockTool {
911                    id: "dup-tool".into(),
912                }),
913            )
914            .build();
915
916        match result {
917            Err(e) => {
918                let err = e.to_string();
919                assert!(
920                    err.contains("dup-tool"),
921                    "error should mention the duplicate tool ID: {err}"
922                );
923            }
924            Ok(_) => panic!("expected build to fail for duplicate tool"),
925        }
926    }
927
928    #[test]
929    fn duplicate_model_errors_at_build() {
930        let result = AgentRuntimeBuilder::new()
931            .with_model_binding(
932                "dup-model",
933                ModelBinding {
934                    provider_id: "p".into(),
935                    upstream_model: "n1".into(),
936                },
937            )
938            .with_model_binding(
939                "dup-model",
940                ModelBinding {
941                    provider_id: "p".into(),
942                    upstream_model: "n2".into(),
943                },
944            )
945            .build();
946
947        match result {
948            Err(e) => {
949                let err = e.to_string();
950                assert!(
951                    err.contains("dup-model"),
952                    "error should mention the duplicate model ID: {err}"
953                );
954            }
955            Ok(_) => panic!("expected build to fail for duplicate model"),
956        }
957    }
958
959    #[test]
960    fn duplicate_provider_errors_at_build() {
961        let result = AgentRuntimeBuilder::new()
962            .with_provider("dup-prov", Arc::new(MockExecutor))
963            .with_provider("dup-prov", Arc::new(MockExecutor))
964            .build();
965
966        match result {
967            Err(e) => {
968                let err = e.to_string();
969                assert!(
970                    err.contains("dup-prov"),
971                    "error should mention the duplicate provider ID: {err}"
972                );
973            }
974            Ok(_) => panic!("expected build to fail for duplicate provider"),
975        }
976    }
977
978    #[cfg(feature = "a2a")]
979    #[test]
980    fn duplicate_backend_factory_errors_at_build() {
981        let result = AgentRuntimeBuilder::new()
982            .with_agent_backend_factory(Arc::new(crate::extensions::a2a::A2aBackendFactory))
983            .build();
984
985        match result {
986            Err(BuildError::BackendRegistryConflict(err)) => {
987                assert!(
988                    err.contains("a2a"),
989                    "error should mention the duplicate backend kind: {err}"
990                );
991            }
992            Err(other) => panic!("expected backend registry conflict, got {other}"),
993            Ok(_) => panic!("expected build to fail for duplicate backend factory"),
994        }
995    }
996}