Skip to main content

awaken_runtime/
builder.rs

1//! Fluent builder API for constructing `AgentRuntime`.
2
3use std::sync::Arc;
4
5use awaken_runtime_contract::StateError;
6use awaken_runtime_contract::contract::commit_coordinator::CommitCoordinator;
7use awaken_runtime_contract::contract::executor::LlmExecutor;
8use awaken_runtime_contract::contract::tool::Tool;
9use awaken_runtime_contract::registry_spec::{AgentSpec, ModelPoolSpec, ModelSpec};
10#[cfg(feature = "a2a")]
11use awaken_runtime_contract::registry_spec::{RemoteAuth, RemoteEndpoint};
12
13#[cfg(feature = "a2a")]
14use crate::backend::ExecutionBackendFactory;
15use crate::plugins::Plugin;
16#[cfg(feature = "a2a")]
17use crate::registry::BackendRegistry;
18#[cfg(feature = "a2a")]
19use crate::registry::composite::{CompositeAgentSpecRegistry, RemoteAgentSource};
20#[cfg(feature = "a2a")]
21use crate::registry::memory::MapBackendRegistry;
22use crate::registry::memory::{
23    MapAgentSpecRegistry, MapModelRegistry, MapPluginSource, MapProviderRegistry, MapToolRegistry,
24};
25use crate::registry::snapshot::RegistryHandle;
26use crate::registry::traits::{AgentSpecRegistry, RegistrySet};
27use crate::runtime::AgentRuntime;
28
29/// Error returned when the builder cannot construct the runtime.
30#[derive(Debug, thiserror::Error)]
31pub enum BuildError {
32    #[error("state error: {0}")]
33    State(#[from] StateError),
34    #[error("agent registry conflict: {0}")]
35    AgentRegistryConflict(String),
36    #[error("tool registry conflict: {0}")]
37    ToolRegistryConflict(String),
38    #[error("model registry conflict: {0}")]
39    ModelRegistryConflict(String),
40    #[error("provider registry conflict: {0}")]
41    ProviderRegistryConflict(String),
42    #[error("plugin registry conflict: {0}")]
43    PluginRegistryConflict(String),
44    #[cfg(feature = "a2a")]
45    #[error("backend registry conflict: {0}")]
46    BackendRegistryConflict(String),
47    #[error("agent validation failed: {0}")]
48    ValidationFailed(String),
49    /// ADR-0036 D8: persisted runs require an explicit `CommitCoordinator`.
50    /// A `ThreadRunStore` without one would silently run with non-atomic
51    /// checkpoint/event commits.
52    #[error(
53        "ADR-0036 D8: `with_thread_run_store` requires a paired `with_commit_coordinator` \
54         supplied by the store/server integration"
55    )]
56    CommitCoordinatorRequired,
57    #[error("config validation failed: {0}")]
58    ConfigValidation(#[from] awaken_runtime_contract::ConfigValidationError),
59    #[cfg(feature = "a2a")]
60    #[error("discovery failed: {0}")]
61    DiscoveryFailed(#[from] crate::registry::composite::DiscoveryError),
62}
63
64/// Fluent API for constructing an `AgentRuntime`.
65///
66/// Collects agent specs, tools, plugins, models, providers, and optionally
67/// a store, then builds the fully resolved runtime.
68pub struct AgentRuntimeBuilder {
69    agents: MapAgentSpecRegistry,
70    tools: MapToolRegistry,
71    models: MapModelRegistry,
72    providers: MapProviderRegistry,
73    plugins: MapPluginSource,
74    #[cfg(feature = "a2a")]
75    backends: MapBackendRegistry,
76    commit_coordinator: Option<Arc<dyn CommitCoordinator>>,
77    profile_store: Option<Arc<dyn awaken_runtime_contract::contract::profile_store::ProfileStore>>,
78    errors: Vec<BuildError>,
79    #[cfg(feature = "a2a")]
80    remote_sources: Vec<RemoteAgentSource>,
81}
82
83impl AgentRuntimeBuilder {
84    pub fn new() -> Self {
85        Self {
86            agents: MapAgentSpecRegistry::new(),
87            tools: MapToolRegistry::new(),
88            models: MapModelRegistry::new(),
89            providers: MapProviderRegistry::new(),
90            plugins: MapPluginSource::new(),
91            #[cfg(feature = "a2a")]
92            backends: MapBackendRegistry::with_default_remote_backends(),
93            commit_coordinator: None,
94            profile_store: None,
95            errors: Vec::new(),
96            #[cfg(feature = "a2a")]
97            remote_sources: Vec::new(),
98        }
99    }
100
101    /// Register an agent spec.
102    pub fn with_agent_spec(mut self, spec: AgentSpec) -> Self {
103        if let Err(e) = self.agents.register_spec(spec) {
104            self.errors.push(e);
105        }
106        self
107    }
108
109    /// Register multiple agent specs.
110    pub fn with_agent_specs(mut self, specs: impl IntoIterator<Item = AgentSpec>) -> Self {
111        for spec in specs {
112            if let Err(e) = self.agents.register_spec(spec) {
113                self.errors.push(e);
114            }
115        }
116        self
117    }
118
119    /// Register a tool by ID.
120    pub fn with_tool(mut self, id: impl Into<String>, tool: Arc<dyn Tool>) -> Self {
121        if let Err(e) = self.tools.register_tool(id, tool) {
122            self.errors.push(e);
123        }
124        self
125    }
126
127    /// Register a plugin by ID.
128    pub fn with_plugin(mut self, id: impl Into<String>, plugin: Arc<dyn Plugin>) -> Self {
129        if let Err(e) = self.plugins.register_plugin(id, plugin) {
130            self.errors.push(e);
131        }
132        self
133    }
134
135    /// Register a model offering. The id is extracted from `spec.id`.
136    pub fn with_model(mut self, spec: ModelSpec) -> Self {
137        // Pre-check at the builder surface so the user-visible error is
138        // ConfigValidation::DuplicateModelId, matching the bulk-config path.
139        if self.models.contains_key(&spec.id) {
140            self.errors.push(BuildError::ConfigValidation(
141                awaken_runtime_contract::ConfigValidationError::DuplicateModelId {
142                    id: spec.id.clone(),
143                },
144            ));
145            return self;
146        }
147        if let Err(e) = self.models.register_model(spec) {
148            self.errors.push(e);
149        }
150        self
151    }
152
153    /// Register a model pool. The id is extracted from `spec.id` and shares the
154    /// model id namespace, so an `AgentSpec.model_id` may reference it exactly
155    /// like a single model.
156    pub fn with_model_pool(mut self, spec: ModelPoolSpec) -> Self {
157        if let Err(e) = self.models.register_model_pool(spec) {
158            self.errors.push(e);
159        }
160        self
161    }
162
163    /// Register a provider (LLM executor) by ID.
164    pub fn with_provider(mut self, id: impl Into<String>, executor: Arc<dyn LlmExecutor>) -> Self {
165        if let Err(e) = self.providers.register_provider(id, executor) {
166            self.errors.push(e);
167        }
168        self
169    }
170
171    /// Register an explicit mock provider and model offering.
172    pub fn with_mock_provider_profile(
173        mut self,
174        profile: crate::engine::MockProviderProfile,
175    ) -> Self {
176        let provider_id = profile.provider_id.clone();
177        if let Err(e) = self
178            .providers
179            .register_provider(provider_id, profile.executor())
180        {
181            self.errors.push(e);
182        }
183        if let Err(e) = self.models.register_model(profile.model_spec()) {
184            self.errors.push(e);
185        }
186        self
187    }
188
189    /// ADR-0036 D8 test/development convenience: install an in-memory store
190    /// paired with a `MemoryCommitCoordinator` that wraps the same handle, so
191    /// the runtime tee and checkpoint writes share one transaction scope and
192    /// the runtime adopts the coordinator's `reader()` as its checkpoint read
193    /// port. Persistence is always coordinator-mediated now (ADR-0038 D7), so
194    /// there is no store-without-coordinator shape to reject.
195    #[cfg(feature = "test-utils")]
196    pub fn with_in_memory_thread_run_store(
197        mut self,
198        store: Arc<awaken_stores::InMemoryStore>,
199    ) -> Self {
200        let coordinator = awaken_stores::MemoryCommitCoordinator::wrap(store);
201        self.commit_coordinator = Some(coordinator as Arc<dyn CommitCoordinator>);
202        self
203    }
204
205    /// Wire a `CommitCoordinator` for atomic checkpoint commits across
206    /// `ThreadRunStore` and `EventStore` writes (ADR-0036). When set, the
207    /// runtime tees durable canonical drafts through the coordinator at
208    /// checkpoint cadence instead of letting `ThreadRunStore::checkpoint`
209    /// and `EventWriter::append` run in independent transactions.
210    pub fn with_commit_coordinator(mut self, coordinator: Arc<dyn CommitCoordinator>) -> Self {
211        self.commit_coordinator = Some(coordinator);
212        self
213    }
214
215    /// Set the profile store for cross-run key-value persistence.
216    pub fn with_profile_store(
217        mut self,
218        store: Arc<dyn awaken_runtime_contract::contract::profile_store::ProfileStore>,
219    ) -> Self {
220        self.profile_store = Some(store);
221        self
222    }
223
224    /// Add a named remote A2A agent source for discovery.
225    ///
226    /// When remote sources are configured, the builder creates a
227    /// [`CompositeAgentSpecRegistry`] that combines local agents with
228    /// agents discovered from remote A2A endpoints. The `name` is used
229    /// for namespaced agent lookup (e.g., `"cloud/translator"`).
230    #[cfg(feature = "a2a")]
231    pub fn with_remote_agents(
232        mut self,
233        name: impl Into<String>,
234        base_url: impl Into<String>,
235        bearer_token: Option<String>,
236    ) -> Self {
237        self.remote_sources.push(RemoteAgentSource::from_endpoint(
238            name,
239            RemoteEndpoint {
240                base_url: base_url.into(),
241                auth: bearer_token.map(RemoteAuth::bearer),
242                ..Default::default()
243            },
244        ));
245        self
246    }
247
248    /// Register a remote delegate backend factory by its backend kind.
249    #[cfg(feature = "a2a")]
250    pub fn with_agent_backend_factory(mut self, factory: Arc<dyn ExecutionBackendFactory>) -> Self {
251        if let Err(e) = self.backends.register_backend_factory(factory) {
252            self.errors.push(e);
253        }
254        self
255    }
256
257    /// Build the `AgentRuntime` and validate all registered agents can
258    /// resolve successfully.
259    ///
260    /// Performs a dry-run resolve for every registered agent, catching
261    /// configuration errors (missing models, providers, plugins) at build time.
262    /// Use [`build_unchecked()`](Self::build_unchecked) to skip validation.
263    pub fn build(self) -> Result<AgentRuntime, BuildError> {
264        let runtime = self.build_unchecked()?;
265        let resolver = runtime.resolver();
266        #[cfg(feature = "a2a")]
267        let registries = runtime.registry_set();
268        let mut errors = Vec::new();
269        for agent_id in resolver.agent_ids() {
270            #[cfg(feature = "a2a")]
271            {
272                if let Some(spec) = registries
273                    .as_ref()
274                    .and_then(|set| set.agents.get_agent(&agent_id))
275                    && spec.uses_remote_backend()
276                {
277                    let endpoint = match spec.remote_endpoint() {
278                        Ok(Some(endpoint)) => endpoint,
279                        Ok(None) => {
280                            errors.push(format!(
281                                "{agent_id}: invalid remote backend '{}' config",
282                                spec.backend.kind
283                            ));
284                            continue;
285                        }
286                        Err(error) => {
287                            errors.push(format!(
288                                "{agent_id}: invalid remote backend '{}' config: {error}",
289                                spec.backend.kind
290                            ));
291                            continue;
292                        }
293                    };
294                    let Some(factory) = registries
295                        .as_ref()
296                        .and_then(|set| set.backends.get_backend_factory(&endpoint.backend))
297                    else {
298                        errors.push(format!(
299                            "{agent_id}: unsupported remote backend '{}'",
300                            endpoint.backend
301                        ));
302                        continue;
303                    };
304                    if let Err(error) = factory.validate(&endpoint) {
305                        errors.push(format!("{agent_id}: {error}"));
306                    }
307                    continue;
308                }
309            }
310
311            if let Err(e) = resolver.resolve(&agent_id) {
312                errors.push(format!("{agent_id}: {e}"));
313            }
314        }
315        if !errors.is_empty() {
316            return Err(BuildError::ValidationFailed(errors.join("; ")));
317        }
318        Ok(runtime)
319    }
320
321    /// Build the `AgentRuntime` from the accumulated configuration,
322    /// skipping agent validation.
323    ///
324    /// Prefer [`build()`](Self::build) which validates all registered agents
325    /// can resolve successfully at build time.
326    pub fn build_unchecked(mut self) -> Result<AgentRuntime, BuildError> {
327        if !self.errors.is_empty() {
328            return Err(self.errors.remove(0));
329        }
330
331        #[cfg(feature = "a2a")]
332        let (agents, composite_registry): (Arc<dyn AgentSpecRegistry>, _) =
333            if self.remote_sources.is_empty() {
334                (Arc::new(self.agents), None)
335            } else {
336                let mut composite = CompositeAgentSpecRegistry::new(Arc::new(self.agents));
337                for source in self.remote_sources {
338                    composite.add_remote(source);
339                }
340                let arc = Arc::new(composite);
341                (Arc::clone(&arc) as Arc<dyn AgentSpecRegistry>, Some(arc))
342            };
343        #[cfg(not(feature = "a2a"))]
344        let agents: Arc<dyn AgentSpecRegistry> = Arc::new(self.agents);
345
346        let registry_set = RegistrySet {
347            agents,
348            tools: Arc::new(self.tools),
349            models: Arc::new(self.models),
350            providers: Arc::new(self.providers),
351            plugins: Arc::new(self.plugins),
352            #[cfg(feature = "a2a")]
353            backends: Arc::new(self.backends) as Arc<dyn BackendRegistry>,
354        };
355
356        let registry_handle = RegistryHandle::new(registry_set.clone());
357        let resolver_impl = Arc::new(crate::registry::resolve::DynamicRegistryResolver::new(
358            registry_handle.clone(),
359        ));
360        let resolver: Arc<dyn crate::registry::AgentResolver> = resolver_impl.clone();
361        let run_resolver: Arc<dyn crate::resolution::Resolver> = resolver_impl;
362
363        let mut runtime = AgentRuntime::new_with_execution_resolver(resolver)
364            .with_run_resolver(run_resolver)
365            .with_registry_handle(registry_handle);
366
367        #[cfg(feature = "a2a")]
368        if let Some(composite) = composite_registry {
369            runtime = runtime.with_composite_registry(composite);
370        }
371
372        if let Some(coordinator) = self.commit_coordinator {
373            runtime = runtime.with_commit_coordinator(coordinator);
374        }
375
376        if let Some(store) = self.profile_store {
377            runtime = runtime.with_profile_store(store);
378        }
379
380        Ok(runtime)
381    }
382
383    /// Build and initialize (async). Discovers remote agents after build.
384    #[cfg(feature = "a2a")]
385    pub async fn build_and_discover(self) -> Result<AgentRuntime, BuildError> {
386        let runtime = self.build_unchecked()?;
387        if let Some(composite) = runtime.composite_registry() {
388            composite.discover().await?;
389        }
390        Ok(runtime)
391    }
392}
393
394impl Default for AgentRuntimeBuilder {
395    fn default() -> Self {
396        Self::new()
397    }
398}
399
400#[cfg(test)]
401#[path = "builder_tests.rs"]
402mod tests;