1use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::Duration;
6
7use futures::stream::{self, StreamExt};
8use meerkat_client::LlmClient;
9use meerkat_mob::{MobDefinition, MobStorage, SpawnMemberSpec};
10
11use crate::console_aggregator::{ConsoleLogStore, InMemoryConsoleLogStore, SqliteConsoleLogStore};
12use crate::contact_directory::ContactDirectory;
13use crate::identity_first::{
14 AgentCustomizer, AgentMemoryConfig, AgentMemoryCustomizer, AgentMemoryProvider,
15 AgentMemoryRuntimeInjector, AgentRuntimeServices, ContinuitySessionStoreAdapter,
16 DurabilityPolicy, IdentityFirstRuntimeContext, IdentityRuntime, IdentityRuntimeConfig,
17 LocalContinuityStore, LocalLeaseProvider, MarkdownAgentMemoryStore, RosterContext,
18 RosterProvider, TopologyProvider, lazy_register_flow, restore_flow,
19};
20use crate::mob_handle_runtime::{
21 CapabilityFlags, MobBootstrapOptions, MobBootstrapSpec, SessionHook,
22};
23use crate::runtime::{
24 InMemoryMetadataStore, PersistentMetadataStore, RuntimeOptions, SqliteMetadataStore,
25};
26use crate::types::{EventEnvelope, MobKitConfig, UnifiedEvent};
27
28use super::edge_types::{Discovery, EdgeDiscovery, PreSpawnHook};
29use super::types::{
30 UnifiedRuntimeBootstrapError, UnifiedRuntimeBuilderError, UnifiedRuntimeBuilderField,
31};
32use super::{
33 DEFAULT_DRAIN_TIMEOUT, ErrorHook, EventLogConfig, PostReconcileHook, PostSpawnHook,
34 UnifiedRuntime, discovery_spec_to_spawn_spec,
35};
36
37pub(crate) enum DefinitionSource {
39 Inline(Box<MobDefinition>),
40 TomlPath(PathBuf),
41}
42
43const DEFAULT_MAX_SESSIONS: usize = 64;
45
46const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
48
49#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub enum IdentityBootstrapMode {
53 #[default]
56 EagerMaterialize,
57 LazyMaterialize,
60 LazyWithBackgroundWarm { concurrency: usize },
63}
64
65#[derive(Default)]
66pub struct UnifiedRuntimeBuilder {
67 mob_spec: Option<MobBootstrapSpec>,
69
70 definition_source: Option<DefinitionSource>,
72 persistent_state_path: Option<PathBuf>,
73 session_hook: Option<Arc<dyn SessionHook>>,
74 custom_session_store: Option<Arc<dyn meerkat::SessionStore>>,
75 meerkat_config: Option<meerkat::Config>,
76 default_llm_client: Option<Arc<dyn LlmClient>>,
77 max_sessions: Option<usize>,
78 capability_flags: CapabilityFlags,
79
80 continuity_store: Option<Arc<dyn crate::identity_first::contracts::ContinuityStore>>,
82 lease_provider: Option<Arc<dyn crate::identity_first::contracts::LeaseProvider>>,
83 roster_provider: Option<Arc<dyn RosterProvider>>,
84 topology_provider: Option<Arc<dyn TopologyProvider>>,
85 agent_customizer: Option<Arc<dyn AgentCustomizer>>,
86 agent_memory_provider: Option<Arc<dyn AgentMemoryProvider>>,
87 agent_memory_config: Option<AgentMemoryConfig>,
88 agent_memory_from_persistent_state: bool,
89 agent_memory_engines: Option<crate::memory_wiring::MemoryEnginesConfig>,
90 identity_bootstrap_mode: IdentityBootstrapMode,
91 identity_runtime_instance_id: Option<String>,
92 scratch_dir: Option<PathBuf>,
93 blob_store: Option<Arc<dyn meerkat_core::BlobStore>>,
94 console_log_store: Option<Arc<dyn ConsoleLogStore>>,
95
96 module_config: Option<MobKitConfig>,
98 module_agent_events: Vec<EventEnvelope<UnifiedEvent>>,
99 timeout: Option<Duration>,
100 options: RuntimeOptions,
101 post_spawn_hook: Option<PostSpawnHook>,
102 post_reconcile_hook: Option<PostReconcileHook>,
103 error_hook: Option<ErrorHook>,
104 event_log_config: Option<EventLogConfig>,
105 drain_timeout: Option<Duration>,
106 discovery: Option<Box<dyn Discovery>>,
107 pre_spawn_hook: Option<PreSpawnHook>,
108 edge_discovery: Option<Box<dyn EdgeDiscovery>>,
109 contact_directory: Option<ContactDirectory>,
110 persistent_metadata: Option<Arc<dyn PersistentMetadataStore>>,
111 access_controller: Option<crate::access::AccessController>,
112 topology_control_policy: crate::topology_control::TopologyControlPolicy,
113}
114
115impl UnifiedRuntimeBuilder {
116 pub fn definition(mut self, def: MobDefinition) -> Self {
122 self.definition_source = Some(DefinitionSource::Inline(Box::new(def)));
123 self
124 }
125
126 pub fn definition_path(mut self, path: impl Into<PathBuf>) -> Self {
128 self.definition_source = Some(DefinitionSource::TomlPath(path.into()));
129 self
130 }
131
132 pub fn persistent_state(mut self, path: impl Into<PathBuf>) -> Self {
138 self.persistent_state_path = Some(path.into());
139 self
140 }
141
142 pub fn session_hook(mut self, hook: Arc<dyn SessionHook>) -> Self {
144 self.session_hook = Some(hook);
145 self
146 }
147
148 pub fn session_store(mut self, store: Arc<dyn meerkat::SessionStore>) -> Self {
153 self.custom_session_store = Some(store);
154 self
155 }
156
157 pub fn meerkat_config(mut self, config: meerkat::Config) -> Self {
164 self.meerkat_config = Some(config);
165 self
166 }
167
168 pub fn default_llm_client(mut self, client: Arc<dyn LlmClient>) -> Self {
170 self.default_llm_client = Some(client);
171 self
172 }
173
174 pub fn max_sessions(mut self, max_sessions: usize) -> Self {
180 self.max_sessions = Some(max_sessions);
181 self
182 }
183
184 pub fn continuity_store(
188 mut self,
189 store: Arc<dyn crate::identity_first::contracts::ContinuityStore>,
190 ) -> Self {
191 self.continuity_store = Some(store);
192 self
193 }
194
195 pub fn lease_provider(
199 mut self,
200 provider: Arc<dyn crate::identity_first::contracts::LeaseProvider>,
201 ) -> Self {
202 self.lease_provider = Some(provider);
203 self
204 }
205
206 pub fn roster_provider(mut self, provider: Arc<dyn RosterProvider>) -> Self {
208 self.roster_provider = Some(provider);
209 self
210 }
211
212 pub fn topology_provider(mut self, provider: Arc<dyn TopologyProvider>) -> Self {
214 self.topology_provider = Some(provider);
215 self
216 }
217
218 pub fn agent_customizer(mut self, customizer: Arc<dyn AgentCustomizer>) -> Self {
220 self.agent_customizer = Some(customizer);
221 self
222 }
223
224 pub fn agent_memory(
226 mut self,
227 provider: Arc<dyn AgentMemoryProvider>,
228 config: AgentMemoryConfig,
229 ) -> Self {
230 self.agent_memory_provider = Some(provider);
231 self.agent_memory_config = Some(config);
232 self
233 }
234
235 pub fn persistent_agent_memory(mut self, config: AgentMemoryConfig) -> Self {
238 self.agent_memory_from_persistent_state = true;
239 self.agent_memory_config = Some(config);
240 self
241 }
242
243 pub fn persistent_agent_memory_stack(
255 mut self,
256 config: AgentMemoryConfig,
257 engines: crate::memory_wiring::MemoryEnginesConfig,
258 ) -> Self {
259 self.agent_memory_config = Some(config);
260 self.agent_memory_engines = Some(engines);
261 self
262 }
263
264 fn composed_agent_customizer(
265 &self,
266 memory_provider: Option<Arc<dyn AgentMemoryProvider>>,
267 ) -> Option<Arc<dyn AgentCustomizer>> {
268 match memory_provider {
269 Some(provider) => Some(Arc::new(AgentMemoryCustomizer::wrap(
270 self.agent_customizer.clone(),
271 provider,
272 self.agent_memory_config.clone().unwrap_or_default(),
273 ))),
274 None => self.agent_customizer.clone(),
275 }
276 }
277
278 pub fn identity_bootstrap_mode(mut self, mode: IdentityBootstrapMode) -> Self {
280 self.identity_bootstrap_mode = mode;
281 self
282 }
283
284 pub fn identity_runtime_instance_id(mut self, id: impl Into<String>) -> Self {
286 self.identity_runtime_instance_id = Some(id.into());
287 self
288 }
289
290 pub fn scratch_dir(mut self, path: impl Into<PathBuf>) -> Self {
294 self.scratch_dir = Some(path.into());
295 self
296 }
297
298 pub fn blob_store(mut self, store: Arc<dyn meerkat_core::BlobStore>) -> Self {
303 self.blob_store = Some(store);
304 self
305 }
306
307 pub fn with_console_log_store(mut self, store: Arc<dyn ConsoleLogStore>) -> Self {
312 self.console_log_store = Some(store);
313 self
314 }
315
316 pub fn builtins(mut self, enabled: bool) -> Self {
318 self.capability_flags.builtins = enabled;
319 self
320 }
321
322 pub fn shell(mut self, enabled: bool) -> Self {
324 self.capability_flags.shell = enabled;
325 self
326 }
327
328 pub fn mob(mut self, enabled: bool) -> Self {
330 self.capability_flags.mob = enabled;
331 self
332 }
333
334 pub fn comms(mut self, enabled: bool) -> Self {
336 self.capability_flags.comms = enabled;
337 self
338 }
339
340 pub fn memory(mut self, enabled: bool) -> Self {
342 self.capability_flags.memory = enabled;
343 self
344 }
345
346 pub fn image_generation(mut self, enabled: bool) -> Self {
352 self.capability_flags.image_generation = enabled;
353 self
354 }
355
356 pub fn mob_spec(mut self, spec: MobBootstrapSpec) -> Self {
361 self.mob_spec = Some(spec);
362 self
363 }
364
365 pub fn module_config(mut self, config: MobKitConfig) -> Self {
366 self.module_config = Some(config);
367 self
368 }
369
370 pub fn module_agent_events(mut self, events: Vec<EventEnvelope<UnifiedEvent>>) -> Self {
371 self.module_agent_events = events;
372 self
373 }
374
375 pub fn timeout(mut self, timeout: Duration) -> Self {
376 self.timeout = Some(timeout);
377 self
378 }
379
380 pub fn runtime_options(mut self, options: RuntimeOptions) -> Self {
381 self.options = options;
382 self
383 }
384
385 pub fn post_spawn_hook(mut self, hook: PostSpawnHook) -> Self {
386 self.post_spawn_hook = Some(hook);
387 self
388 }
389
390 pub fn post_reconcile_hook(mut self, hook: PostReconcileHook) -> Self {
391 self.post_reconcile_hook = Some(hook);
392 self
393 }
394
395 pub fn on_error(mut self, hook: ErrorHook) -> Self {
396 self.error_hook = Some(hook);
397 self
398 }
399
400 pub fn event_log(mut self, config: EventLogConfig) -> Self {
401 self.event_log_config = Some(config);
402 self
403 }
404
405 pub fn drain_timeout(mut self, timeout: Duration) -> Self {
406 self.drain_timeout = Some(timeout);
407 self
408 }
409
410 pub fn discovery(mut self, discovery: impl Discovery + 'static) -> Self {
411 self.discovery = Some(Box::new(discovery));
412 self
413 }
414
415 pub fn pre_spawn_hook(mut self, hook: PreSpawnHook) -> Self {
416 self.pre_spawn_hook = Some(hook);
417 self
418 }
419
420 pub fn edge_discovery(mut self, edge_discovery: impl EdgeDiscovery + 'static) -> Self {
421 self.edge_discovery = Some(Box::new(edge_discovery));
422 self
423 }
424
425 pub fn contact_directory(mut self, directory: ContactDirectory) -> Self {
427 self.contact_directory = Some(directory);
428 self
429 }
430
431 pub fn persistent_metadata(mut self, store: Arc<dyn PersistentMetadataStore>) -> Self {
441 self.persistent_metadata = Some(store);
442 self
443 }
444
445 pub fn access_controller(mut self, controller: crate::access::AccessController) -> Self {
449 self.access_controller = Some(controller);
450 self
451 }
452
453 pub fn access_control_file(
457 mut self,
458 path: impl Into<PathBuf>,
459 ) -> Result<Self, crate::access::AccessConfigError> {
460 self.access_controller = Some(crate::access::AccessController::load_or_default(path)?);
461 Ok(self)
462 }
463
464 pub fn topology_control(
467 mut self,
468 policy: crate::topology_control::TopologyControlPolicy,
469 ) -> Result<Self, crate::topology_control::TopologyControlError> {
470 policy.validate()?;
471 self.topology_control_policy = policy;
472 Ok(self)
473 }
474
475 pub async fn build(mut self) -> Result<UnifiedRuntime, UnifiedRuntimeBuilderError> {
480 let has_persistent_state = self.persistent_state_path.is_some();
483 let has_continuity_store = self.continuity_store.is_some();
484 let has_lease_provider = self.lease_provider.is_some();
485 let has_roster_provider = self.roster_provider.is_some();
486 let has_topology_provider = self.topology_provider.is_some();
487 let has_agent_customizer = self.agent_customizer.is_some();
496 let has_identity_runtime_instance_id = self.identity_runtime_instance_id.is_some();
497 let has_scratch_dir = self.scratch_dir.is_some();
498 let has_external_identity_storage =
499 has_continuity_store || has_lease_provider || has_scratch_dir;
500 let wants_identity_first = has_external_identity_storage
501 || has_roster_provider
502 || has_topology_provider
503 || has_agent_customizer
504 || has_identity_runtime_instance_id;
505 if self.agent_memory_provider.is_some() && self.agent_memory_from_persistent_state {
506 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
507 "agent_memory() and persistent_agent_memory() are mutually exclusive".to_string(),
508 ));
509 }
510
511 if self.agent_memory_from_persistent_state && !has_persistent_state {
512 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
513 "persistent_agent_memory() requires persistent_state()".to_string(),
514 ));
515 }
516
517 if has_persistent_state && has_external_identity_storage {
521 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
522 "persistent_state() and identity-first continuity_store()/lease_provider()/scratch_dir() setters \
523 are mutually exclusive — use one storage authority"
524 .to_string(),
525 ));
526 }
527
528 if has_persistent_state && wants_identity_first && !has_roster_provider {
529 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
530 "persistent_state() identity-first path requires roster_provider()".to_string(),
531 ));
532 }
533
534 if !has_persistent_state
536 && wants_identity_first
537 && !(has_continuity_store
538 && has_lease_provider
539 && has_roster_provider
540 && has_scratch_dir)
541 {
542 let mut missing = Vec::new();
543 if !has_continuity_store {
544 missing.push("continuity_store");
545 }
546 if !has_lease_provider {
547 missing.push("lease_provider");
548 }
549 if !has_roster_provider {
550 missing.push("roster_provider");
551 }
552 if !has_scratch_dir {
553 missing.push("scratch_dir");
554 }
555 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
556 format!(
557 "identity-first path requires continuity_store() + lease_provider() + \
558 roster_provider() + scratch_dir(); missing: {}",
559 missing.join(", ")
560 ),
561 ));
562 }
563
564 let continuity_session_store = self
565 .continuity_store
566 .as_ref()
567 .map(|store| Arc::new(ContinuitySessionStoreAdapter::new(store.clone())));
568 if let Some(store) = continuity_session_store.as_ref() {
569 self.custom_session_store = Some(store.clone());
570 }
571
572 let mut mob_spec = match self.mob_spec.take() {
575 Some(spec) => {
576 if self.module_config.is_none() {
578 return Err(UnifiedRuntimeBuilderError::MissingRequiredField(
579 UnifiedRuntimeBuilderField::ModuleConfig,
580 ));
581 }
582 if self.timeout.is_none() {
583 return Err(UnifiedRuntimeBuilderError::MissingRequiredField(
584 UnifiedRuntimeBuilderField::Timeout,
585 ));
586 }
587 spec
588 }
589 None => self.resolve_mob_spec().await?,
590 };
591
592 let module_config = self.module_config.take().unwrap_or_else(|| MobKitConfig {
593 modules: Vec::new(),
594 discovery: crate::types::DiscoverySpec {
595 namespace: String::new(),
596 modules: Vec::new(),
597 },
598 pre_spawn: Vec::new(),
599 });
600 let timeout = self.timeout.unwrap_or(DEFAULT_TIMEOUT);
601 if let Some(state_path) = self.persistent_state_path.as_ref() {
602 std::fs::create_dir_all(state_path).map_err(|e| {
603 UnifiedRuntimeBuilderError::Io(format!(
604 "failed to create state directory at {}: {e}",
605 state_path.display()
606 ))
607 })?;
608 }
609 let persistent_agent_memory_provider: Option<Arc<dyn AgentMemoryProvider>> =
610 if self.agent_memory_from_persistent_state {
611 let Some(state_path) = self.persistent_state_path.as_ref() else {
612 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
613 "persistent_agent_memory() requires persistent_state()".to_string(),
614 ));
615 };
616 let memory_path = state_path.join("agent-memory");
617 Some(Arc::new(
618 MarkdownAgentMemoryStore::open(&memory_path).map_err(|e| {
619 UnifiedRuntimeBuilderError::Io(format!(
620 "failed to open agent memory store at {}: {e}",
621 memory_path.display()
622 ))
623 })?,
624 ))
625 } else {
626 None
627 };
628 let stack_sqlite_store = if self.agent_memory_engines.is_some() {
633 let Some(state_path) = self.persistent_state_path.as_ref() else {
634 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
635 "persistent_agent_memory_stack() requires persistent_state()".to_string(),
636 ));
637 };
638 let memory_path = state_path.join("agent-memory-sqlite");
639 Some(
640 crate::memory::sqlite_store::SqliteAgentMemoryStore::open(&memory_path).map_err(
641 |e| {
642 UnifiedRuntimeBuilderError::Io(format!(
643 "failed to open agent memory store at {}: {e}",
644 memory_path.display()
645 ))
646 },
647 )?,
648 )
649 } else {
650 None
651 };
652 let agent_memory_provider = self
653 .agent_memory_provider
654 .clone()
655 .or_else(|| {
656 stack_sqlite_store
657 .clone()
658 .map(|store| Arc::new(store) as Arc<dyn AgentMemoryProvider>)
659 })
660 .or(persistent_agent_memory_provider);
661 let agent_memory_injector = agent_memory_provider.as_ref().map(|provider| {
662 AgentMemoryRuntimeInjector::new(
663 provider.clone(),
664 self.agent_memory_config.clone().unwrap_or_default(),
665 )
666 });
667 let agent_customizer = self.composed_agent_customizer(agent_memory_provider.clone());
668
669 let classic_agent_memory = if wants_identity_first {
676 None
677 } else {
678 agent_memory_provider.clone()
679 };
680 if let Some(provider) = classic_agent_memory.as_ref() {
681 mob_spec.spawn_member_customizer =
682 Some(Arc::new(crate::memory::MemorySpawnCustomizer::new(
683 provider.clone(),
684 self.agent_memory_config.clone().unwrap_or_default(),
685 )));
686 }
687
688 let persistent_metadata: Arc<dyn PersistentMetadataStore> =
697 if let Some(store) = self.persistent_metadata.clone() {
698 store
699 } else if let Some(state_path) = self.persistent_state_path.as_ref() {
700 let metadata_path = state_path.join("mobkit_metadata.sqlite");
701 Arc::new(SqliteMetadataStore::open(&metadata_path).map_err(|e| {
702 UnifiedRuntimeBuilderError::Io(format!(
703 "failed to open mobkit_metadata.sqlite at {}: {e}",
704 metadata_path.display()
705 ))
706 })?)
707 } else {
708 Arc::new(InMemoryMetadataStore::new())
709 };
710 let console_log_store: Arc<dyn ConsoleLogStore> =
711 if let Some(store) = self.console_log_store.clone() {
712 store
713 } else if let Some(state_path) = self.persistent_state_path.as_ref() {
714 let console_log_path = state_path.join("mobkit_console.sqlite");
715 Arc::new(SqliteConsoleLogStore::open(&console_log_path).map_err(|e| {
716 UnifiedRuntimeBuilderError::Io(format!(
717 "failed to open mobkit_console.sqlite at {}: {e}",
718 console_log_path.display()
719 ))
720 })?)
721 } else {
722 Arc::new(InMemoryConsoleLogStore::new())
723 };
724 let runtime = Box::pin(UnifiedRuntime::bootstrap_with_options(
725 mob_spec,
726 module_config,
727 self.module_agent_events,
728 timeout,
729 self.options,
730 persistent_metadata,
731 ))
732 .await
733 .map_err(UnifiedRuntimeBuilderError::Bootstrap)?;
734
735 let session_bridge: Option<Arc<dyn crate::identity_first::bridge::SessionBridge>> = {
739 let handle = runtime.mob_runtime.handle();
740 let session_service = runtime.mob_runtime.session_service().cloned();
741 let session_store = self.custom_session_store.clone();
742 let bridge: Arc<dyn crate::identity_first::bridge::SessionBridge> =
743 if let Some(store) = continuity_session_store.clone() {
744 Arc::new(
745 crate::identity_first::bridge::MobSessionBridge::with_continuity_session_store(
746 handle,
747 store,
748 session_service,
749 ),
750 )
751 } else if let (Some(store), Some(service)) =
752 (session_store.clone(), session_service.clone())
753 {
754 Arc::new(
755 crate::identity_first::bridge::MobSessionBridge::with_session_store_and_service(
756 handle, store, service,
757 ),
758 )
759 } else if let Some(store) = session_store {
760 Arc::new(
761 crate::identity_first::bridge::MobSessionBridge::with_session_store(
762 handle, store,
763 ),
764 )
765 } else if let Some(service) = session_service {
766 Arc::new(
767 crate::identity_first::bridge::MobSessionBridge::with_session_service(
768 handle, service,
769 ),
770 )
771 } else {
772 Arc::new(crate::identity_first::bridge::MobSessionBridge::new(handle))
773 };
774 Some(bridge)
775 };
776
777 let topology_controller = if let Some(state_path) = self.persistent_state_path.as_ref() {
782 crate::topology_control::TopologyController::load_or_default(
783 self.topology_control_policy.clone(),
784 state_path.join("topology-control.json"),
785 )
786 .map_err(|error| UnifiedRuntimeBuilderError::Io(error.to_string()))?
787 } else {
788 crate::topology_control::TopologyController::new(self.topology_control_policy.clone())
789 .map_err(|error| UnifiedRuntimeBuilderError::Io(error.to_string()))?
790 };
791 topology_controller
792 .bind_authority(runtime.mob_id())
793 .await
794 .map_err(|error| UnifiedRuntimeBuilderError::Io(error.to_string()))?;
795
796 let identity_first_context = if wants_identity_first {
797 let (continuity_store, lease_provider): (
798 Arc<dyn crate::identity_first::contracts::ContinuityStore>,
799 Arc<dyn crate::identity_first::contracts::LeaseProvider>,
800 ) = if let Some(state_path) = self.persistent_state_path.as_ref() {
801 let continuity_path = state_path.join("identity_continuity.sqlite");
802 let local_store = LocalContinuityStore::open(&continuity_path).map_err(|e| {
803 UnifiedRuntimeBuilderError::Io(format!(
804 "failed to open identity_continuity.sqlite at {}: {e}",
805 continuity_path.display()
806 ))
807 })?;
808 let high_water = local_store.max_fencing_token().map_err(|e| {
809 UnifiedRuntimeBuilderError::Io(format!(
810 "failed to read identity continuity fencing high-water at {}: {e}",
811 continuity_path.display()
812 ))
813 })?;
814 (
815 Arc::new(local_store),
816 Arc::new(LocalLeaseProvider::with_floor(high_water)),
817 )
818 } else {
819 let Some(continuity_store) = self.continuity_store.clone() else {
820 return Err(UnifiedRuntimeBuilderError::Bootstrap(
821 UnifiedRuntimeBootstrapError::IdentityFirst(
822 "identity-first validation requires continuity_store".to_string(),
823 ),
824 ));
825 };
826 let Some(lease_provider) = self.lease_provider.clone() else {
827 return Err(UnifiedRuntimeBuilderError::Bootstrap(
828 UnifiedRuntimeBootstrapError::IdentityFirst(
829 "identity-first validation requires lease_provider".to_string(),
830 ),
831 ));
832 };
833 (continuity_store, lease_provider)
834 };
835 let Some(roster_provider) = self.roster_provider.clone() else {
836 return Err(UnifiedRuntimeBuilderError::Bootstrap(
837 UnifiedRuntimeBootstrapError::IdentityFirst(
838 "identity-first validation requires roster_provider".to_string(),
839 ),
840 ));
841 };
842 let bridge = session_bridge.clone();
843 let identity_runtime = Arc::new(
844 IdentityRuntime::new(IdentityRuntimeConfig {
845 continuity_store,
846 lease_provider,
847 runtime_instance_id: self
848 .identity_runtime_instance_id
849 .clone()
850 .unwrap_or_else(|| format!("mobkit-{}", std::process::id())),
851 has_runtime_store: true,
852 durability_policy: DurabilityPolicy::SyncWriteThrough,
853 bridge,
854 default_timeout: None,
855 })
856 .with_runtime_services(AgentRuntimeServices::new(runtime.mob_runtime.handle()))
857 .with_reset_roster_provider_context(
858 roster_provider.clone(),
859 Some(runtime.mob_runtime.handle().definition().clone()),
860 ),
861 );
862 identity_runtime
863 .set_agent_customizer(agent_customizer.clone())
864 .await;
865 identity_runtime
866 .set_agent_memory(agent_memory_injector.clone())
867 .await;
868 identity_runtime.set_error_hook(self.error_hook.clone());
869 identity_runtime.set_topology_controller(topology_controller.clone());
870
871 let roster_specs = roster_provider
872 .roster(&RosterContext {
873 mob_definition: Some(runtime.mob_runtime.handle().definition().clone()),
874 previous_identities: Vec::new(),
875 })
876 .await
877 .map_err(|err| {
878 UnifiedRuntimeBuilderError::Bootstrap(
879 UnifiedRuntimeBootstrapError::IdentityFirst(format!(
880 "roster provider failed: {err}"
881 )),
882 )
883 })?;
884
885 match self.identity_bootstrap_mode.clone() {
886 IdentityBootstrapMode::EagerMaterialize => {
887 restore_flow(
888 &identity_runtime,
889 &roster_specs,
890 self.topology_provider.as_deref(),
891 agent_customizer.as_deref(),
892 )
893 .await
894 .map_err(|err| {
895 UnifiedRuntimeBuilderError::Bootstrap(
896 UnifiedRuntimeBootstrapError::IdentityFirst(format!(
897 "restore_flow failed: {err}"
898 )),
899 )
900 })?;
901 }
902 IdentityBootstrapMode::LazyMaterialize => {
903 lazy_register_flow(
904 &identity_runtime,
905 &roster_specs,
906 self.topology_provider.as_deref(),
907 )
908 .await
909 .map_err(|err| {
910 UnifiedRuntimeBuilderError::Bootstrap(
911 UnifiedRuntimeBootstrapError::IdentityFirst(format!(
912 "lazy_register_flow failed: {err}"
913 )),
914 )
915 })?;
916 }
917 IdentityBootstrapMode::LazyWithBackgroundWarm { concurrency } => {
918 if concurrency == 0 {
919 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
920 "LazyWithBackgroundWarm concurrency must be greater than 0".to_string(),
921 ));
922 }
923 lazy_register_flow(
924 &identity_runtime,
925 &roster_specs,
926 self.topology_provider.as_deref(),
927 )
928 .await
929 .map_err(|err| {
930 UnifiedRuntimeBuilderError::Bootstrap(
931 UnifiedRuntimeBootstrapError::IdentityFirst(format!(
932 "lazy_register_flow failed: {err}"
933 )),
934 )
935 })?;
936 let warm_runtime = identity_runtime.clone();
937 let warm_identities = roster_specs
938 .iter()
939 .map(|spec| spec.identity.clone())
940 .collect::<Vec<_>>();
941 tokio::spawn(async move {
942 stream::iter(warm_identities.into_iter().map(|identity| {
943 let runtime = warm_runtime.clone();
944 async move {
945 runtime.best_effort_background_warm_identity(identity).await;
946 }
947 }))
948 .buffer_unordered(concurrency)
949 .collect::<Vec<_>>()
950 .await;
951 });
952 }
953 }
954
955 Some(Arc::new(
956 IdentityFirstRuntimeContext::new_with_lazy_materialization(
957 identity_runtime,
958 roster_provider,
959 self.topology_provider.clone(),
960 agent_customizer.clone(),
961 Some(runtime.mob_runtime.handle().definition().clone()),
962 !matches!(
963 self.identity_bootstrap_mode,
964 IdentityBootstrapMode::EagerMaterialize
965 ),
966 ),
967 ))
968 } else {
969 None
970 };
971 let identity_lease_renewal_task = identity_first_context
972 .as_ref()
973 .map(|context| context.runtime.clone().spawn_lease_renewal_task());
974 let identity_continuity_repair_task = identity_first_context.as_ref().map(|context| {
975 context
976 .clone()
977 .spawn_broken_identity_repair_task(Default::default())
978 });
979
980 let runtime = UnifiedRuntime {
982 access_controller: self.access_controller,
983 topology_controller,
984 post_spawn_hook: self.post_spawn_hook,
985 post_reconcile_hook: self.post_reconcile_hook,
986 error_hook: self.error_hook,
987 drain_timeout: self.drain_timeout.unwrap_or(DEFAULT_DRAIN_TIMEOUT),
988 discovery: self.discovery,
989 edge_discovery: self
993 .edge_discovery
994 .map(std::sync::Arc::<dyn EdgeDiscovery>::from)
995 .or(runtime.edge_discovery),
996 contact_directory: self.contact_directory,
997 session_bridge,
998 identity_first_context,
999 identity_lease_renewal_task: tokio::sync::Mutex::new(identity_lease_renewal_task),
1000 identity_continuity_repair_task: tokio::sync::Mutex::new(
1001 identity_continuity_repair_task,
1002 ),
1003 console_log_store,
1004 ..runtime
1005 };
1006
1007 if let Some(store) = classic_agent_memory
1013 .as_ref()
1014 .and_then(|provider| provider.as_sqlite_store())
1015 {
1016 let llm_writes = self
1017 .agent_memory_config
1018 .as_ref()
1019 .map(|config| config.llm_writes)
1020 .unwrap_or_default();
1021 store.set_llm_write_gate_if_absent(Arc::new(
1022 crate::memory::taint::TaintLlmWriteGate::new(None, llm_writes),
1023 ));
1024 store.set_event_sink_if_absent(runtime.memory_event_sink());
1025 runtime.set_memory_panel_store(store.clone());
1026 }
1027
1028 if let (Some(store), Some(engines)) =
1031 (stack_sqlite_store, self.agent_memory_engines.as_ref())
1032 {
1033 let config = self.agent_memory_config.clone().unwrap_or_default();
1034 let persistent_state = self.persistent_state_path.clone();
1035 let transcript_store: Option<Arc<dyn meerkat::SessionStore>> =
1036 if engines.distiller.enabled || engines.steward.enabled {
1037 let state = persistent_state.as_ref().ok_or_else(|| {
1038 UnifiedRuntimeBuilderError::ConflictingConfiguration(
1039 "agent memory engines require persistent_state()".to_string(),
1040 )
1041 })?;
1042 Some(Arc::new(
1043 meerkat_store::SqliteSessionStore::open(state.join("sessions.db"))
1044 .map_err(|e| {
1045 UnifiedRuntimeBuilderError::Io(format!(
1046 "agent memory session store: {e}"
1047 ))
1048 })?,
1049 ))
1050 } else {
1051 None
1052 };
1053 let stack = crate::memory_wiring::attach_memory_engines(
1054 store,
1055 &config,
1056 engines,
1057 crate::memory_wiring::MemoryStackSeams {
1058 persistent_state,
1059 transcript_store,
1060 event_sink: Some(runtime.memory_event_sink()),
1061 ..Default::default()
1062 },
1063 )
1064 .map_err(UnifiedRuntimeBuilderError::Io)?;
1065 runtime.set_memory_panel_store(stack.store.clone());
1066 std::mem::forget(crate::spawn_member_event_observer(
1068 runtime.mob_handle(),
1069 stack.sinks,
1070 ));
1071 if let Some(steward) = stack.steward.as_ref() {
1072 std::mem::forget(steward.spawn_dream_loop());
1075 }
1076 tracing::info!(
1077 distiller = stack.distiller.is_some(),
1078 steward = stack.steward.is_some(),
1079 "agent memory stack installed (builder path)"
1080 );
1081 }
1082
1083 let pre_spawn_context = if let Some(hook) = self.pre_spawn_hook {
1084 hook().await.map_err(|err| {
1085 UnifiedRuntimeBuilderError::Bootstrap(UnifiedRuntimeBootstrapError::PreSpawnHook(
1086 err.to_string(),
1087 ))
1088 })?
1089 } else {
1090 serde_json::Value::Null
1091 };
1092 if runtime.identity_first_context.is_none()
1093 && let Some(ref discovery) = runtime.discovery
1094 {
1095 let specs = discovery.discover(pre_spawn_context).await;
1096 let spawn_specs: Vec<SpawnMemberSpec> =
1097 specs.iter().map(discovery_spec_to_spawn_spec).collect();
1098 runtime
1099 .spawn_many(spawn_specs)
1100 .await
1101 .map_err(UnifiedRuntimeBootstrapError::Mob)
1102 .map_err(UnifiedRuntimeBuilderError::Bootstrap)?;
1103 }
1104
1105 if runtime.edge_discovery.is_some()
1107 || runtime.topology_controller.revision().await > 0
1108 || runtime.topology_controller.has_pending().await
1109 {
1110 let report = runtime.reconcile_edges().await;
1111 *runtime.bootstrap_edges_report.write().await = Some(report);
1112 }
1113
1114 let mut runtime = runtime;
1116 if let Some(event_log_config) = self.event_log_config {
1117 runtime.start_event_log(event_log_config);
1118 }
1119
1120 Ok(runtime)
1121 }
1122
1123 async fn resolve_mob_spec(&self) -> Result<MobBootstrapSpec, UnifiedRuntimeBuilderError> {
1126 let mut caps = self.capability_flags;
1127 let definition = match self.definition_source {
1128 Some(DefinitionSource::Inline(ref def)) => *def.clone(),
1129 Some(DefinitionSource::TomlPath(ref path)) => {
1130 let toml_content = std::fs::read_to_string(path).map_err(|e| {
1131 UnifiedRuntimeBuilderError::Io(format!(
1132 "failed to read definition TOML at {}: {e}",
1133 path.display()
1134 ))
1135 })?;
1136 MobDefinition::from_toml(&toml_content).map_err(|e| {
1137 UnifiedRuntimeBuilderError::DefinitionLoad(format!(
1138 "failed to parse definition TOML at {}: {e}",
1139 path.display()
1140 ))
1141 })?
1142 }
1143 None => {
1144 return Err(UnifiedRuntimeBuilderError::MissingRequiredField(
1145 UnifiedRuntimeBuilderField::MobSpec,
1146 ));
1147 }
1148 };
1149 caps.image_generation |=
1150 crate::mob_handle_runtime::mob_definition_may_use_image_generation(&definition);
1151 let max_sessions = self.max_sessions.unwrap_or(DEFAULT_MAX_SESSIONS);
1152 if max_sessions == 0 {
1153 return Err(UnifiedRuntimeBuilderError::ConflictingConfiguration(
1154 "max_sessions() must be greater than 0".to_string(),
1155 ));
1156 }
1157
1158 let hook = self
1159 .session_hook
1160 .as_ref()
1161 .map(|h| -> crate::mob_handle_runtime::PreBuildHook {
1162 let hook = h.clone();
1163 Arc::new(
1164 move |req: &mut meerkat_core::service::CreateSessionRequest| {
1165 let hook = hook.clone();
1166 Box::pin(async move { hook.before_create(req).await })
1167 },
1168 )
1169 });
1170
1171 let after_hook: Option<crate::mob_handle_runtime::AfterCreateHook> = self
1172 .session_hook
1173 .as_ref()
1174 .map(|h| -> crate::mob_handle_runtime::AfterCreateHook {
1175 let hook = h.clone();
1176 Arc::new(move |session_id, ctx| {
1177 let hook = hook.clone();
1178 Box::pin(async move {
1179 hook.after_create(&session_id, &ctx).await;
1180 })
1181 })
1182 });
1183
1184 let mut spec = if let Some(ref state_path) = self.persistent_state_path {
1186 std::fs::create_dir_all(state_path).map_err(|e| {
1187 UnifiedRuntimeBuilderError::Io(format!(
1188 "failed to create state directory at {}: {e}",
1189 state_path.display()
1190 ))
1191 })?;
1192
1193 let session_store: Arc<dyn meerkat::SessionStore> =
1194 if let Some(ref store) = self.custom_session_store {
1195 store.clone()
1196 } else {
1197 let sqlite_path = state_path.join("sessions.db");
1198 Arc::new(
1199 meerkat_store::SqliteSessionStore::open(sqlite_path).map_err(|e| {
1200 UnifiedRuntimeBuilderError::Io(format!(
1201 "failed to open SQLite session store: {e}"
1202 ))
1203 })?,
1204 )
1205 };
1206 let mob_storage = MobStorage::in_memory();
1207
1208 MobBootstrapSpec::persistent_inner(
1209 definition,
1210 mob_storage,
1211 state_path.clone(),
1212 max_sessions,
1213 session_store,
1214 self.blob_store.clone(),
1215 hook,
1216 caps,
1217 after_hook.clone(),
1218 self.meerkat_config.clone(),
1219 )
1220 } else if let Some(ref scratch_dir) = self.scratch_dir {
1221 std::fs::create_dir_all(scratch_dir).map_err(|e| {
1222 UnifiedRuntimeBuilderError::Io(format!(
1223 "failed to create scratch directory at {}: {e}",
1224 scratch_dir.display()
1225 ))
1226 })?;
1227
1228 MobBootstrapSpec::ephemeral_runtime_backed_inner(
1229 definition,
1230 MobStorage::in_memory(),
1231 scratch_dir.clone(),
1232 max_sessions,
1233 self.custom_session_store.clone(),
1234 self.blob_store.clone(),
1235 hook,
1236 caps,
1237 after_hook,
1238 self.meerkat_config.clone(),
1239 )
1240 } else {
1241 let temp_dir = tempfile::tempdir().map_err(|e| {
1243 UnifiedRuntimeBuilderError::Io(format!("failed to create temp dir: {e}"))
1244 })?;
1245 let store_path = temp_dir.path().to_path_buf();
1246
1247 let mut spec = MobBootstrapSpec::ephemeral_runtime_backed_inner(
1248 definition,
1249 MobStorage::in_memory(),
1250 store_path,
1251 max_sessions,
1252 self.custom_session_store.clone(),
1253 self.blob_store.clone(),
1254 hook,
1255 caps,
1256 after_hook,
1257 self.meerkat_config.clone(),
1258 );
1259 spec._ephemeral_dir = Some(Arc::new(temp_dir));
1260 spec
1261 };
1262
1263 spec.options = MobBootstrapOptions {
1264 allow_ephemeral_sessions: true,
1265 notify_orchestrator_on_resume: true,
1266 default_llm_client: self.default_llm_client.clone(),
1267 };
1268
1269 Ok(spec)
1270 }
1271}
1272
1273#[cfg(test)]
1274#[allow(clippy::expect_used)]
1275mod tests {
1276 use super::*;
1277 use meerkat_core::service::{
1278 CreateSessionRequest, DeferredPromptPolicy, InitialTurnPolicy, SessionService,
1279 };
1280
1281 fn deferred_capacity_request(prompt: impl Into<String>) -> CreateSessionRequest {
1282 let build = meerkat_core::service::SessionBuildOptions {
1283 llm_client_override: Some(meerkat::encode_llm_client_override_for_service(Arc::new(
1284 meerkat_client::TestClient::default(),
1285 ))),
1286 ..Default::default()
1287 };
1288
1289 CreateSessionRequest {
1290 model: "gpt-5.5".to_string(),
1291 prompt: meerkat_core::ContentInput::Text(prompt.into()),
1292 system_prompt: meerkat_core::config::SystemPromptOverride::Inherit,
1293 max_tokens: None,
1294 event_tx: None,
1295 initial_turn: InitialTurnPolicy::Defer,
1296 deferred_prompt_policy: DeferredPromptPolicy::Discard,
1297 build: Some(build),
1298 labels: None,
1299 injected_context: Vec::new(),
1300 }
1301 }
1302
1303 #[tokio::test]
1304 async fn definition_based_ephemeral_spec_provides_runtime_adapter() {
1305 let definition = meerkat_mob::MobDefinition::from_toml(
1306 r#"
1307[mob]
1308id = "builder-ephemeral"
1309
1310[profiles.worker]
1311model = "gpt-5.5"
1312runtime_mode = "autonomous_host"
1313
1314[profiles.worker.tools]
1315comms = true
1316"#,
1317 )
1318 .expect("definition parses");
1319
1320 let builder = UnifiedRuntimeBuilder::default().definition(definition);
1321 let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1322 assert!(
1323 spec.runtime_adapter.is_some(),
1324 "definition-based ephemeral specs should expose runtime authority",
1325 );
1326 }
1327
1328 #[tokio::test]
1329 async fn definition_based_ephemeral_spec_uses_configured_max_sessions() {
1330 let definition = meerkat_mob::MobDefinition::from_toml(
1331 r#"
1332[mob]
1333id = "builder-max-sessions"
1334
1335[profiles.worker]
1336model = "gpt-5.5"
1337runtime_mode = "autonomous_host"
1338"#,
1339 )
1340 .expect("definition parses");
1341
1342 let builder = UnifiedRuntimeBuilder::default()
1343 .definition(definition)
1344 .max_sessions(65);
1345 let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1346
1347 for index in 0..65 {
1348 SessionService::create_session(
1349 spec.session_service.as_ref(),
1350 deferred_capacity_request(format!("session {index}")),
1351 )
1352 .await
1353 .expect("configured capacity should admit session");
1354 }
1355
1356 let blocked = SessionService::create_session(
1357 spec.session_service.as_ref(),
1358 deferred_capacity_request("one too many"),
1359 )
1360 .await
1361 .expect_err("configured capacity should block the next session");
1362 assert!(
1363 blocked.to_string().contains("Max sessions reached (65/65)"),
1364 "unexpected capacity error: {blocked}",
1365 );
1366 }
1367
1368 #[tokio::test]
1369 async fn definition_based_spec_accepts_custom_meerkat_config() {
1370 let definition = meerkat_mob::MobDefinition::from_toml(
1371 r#"
1372[mob]
1373id = "builder-custom-config"
1374
1375[profiles.worker]
1376model = "gpt-5.5"
1377runtime_mode = "autonomous_host"
1378"#,
1379 )
1380 .expect("definition parses");
1381 let mut config = meerkat::Config::default();
1382 config.compaction.auto_compact_threshold = 42_000;
1383 config.compaction.auto_compact_threshold_explicit = true;
1384 config.compaction.recent_turn_budget = 2;
1385
1386 let builder = UnifiedRuntimeBuilder::default()
1387 .definition(definition)
1388 .meerkat_config(config)
1389 .max_sessions(1);
1390 let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1391
1392 SessionService::create_session(
1393 spec.session_service.as_ref(),
1394 deferred_capacity_request("custom config session"),
1395 )
1396 .await
1397 .expect("custom Meerkat config should still build a usable session service");
1398 }
1399
1400 #[tokio::test]
1401 async fn definition_based_persistent_spec_uses_configured_max_sessions() {
1402 let definition = meerkat_mob::MobDefinition::from_toml(
1403 r#"
1404[mob]
1405id = "builder-persistent-max-sessions"
1406
1407[profiles.worker]
1408model = "gpt-5.5"
1409runtime_mode = "autonomous_host"
1410"#,
1411 )
1412 .expect("definition parses");
1413 let tmp = tempfile::tempdir().expect("temp dir");
1414
1415 let builder = UnifiedRuntimeBuilder::default()
1416 .definition(definition)
1417 .persistent_state(tmp.path().join("state"))
1418 .max_sessions(2);
1419 let spec = builder.resolve_mob_spec().await.expect("spec resolves");
1420
1421 for index in 0..2 {
1422 SessionService::create_session(
1423 spec.session_service.as_ref(),
1424 deferred_capacity_request(format!("persistent session {index}")),
1425 )
1426 .await
1427 .expect("configured persistent capacity should admit session");
1428 }
1429
1430 let blocked = SessionService::create_session(
1431 spec.session_service.as_ref(),
1432 deferred_capacity_request("persistent one too many"),
1433 )
1434 .await
1435 .expect_err("configured persistent capacity should block the next session");
1436 assert!(
1437 blocked.to_string().contains("Max sessions reached (2/2)"),
1438 "unexpected capacity error: {blocked}",
1439 );
1440 }
1441
1442 #[tokio::test]
1443 async fn definition_based_spec_rejects_zero_max_sessions() {
1444 let definition = meerkat_mob::MobDefinition::from_toml(
1445 r#"
1446[mob]
1447id = "builder-zero-max-sessions"
1448
1449[profiles.worker]
1450model = "gpt-5.5"
1451"#,
1452 )
1453 .expect("definition parses");
1454
1455 let result = UnifiedRuntimeBuilder::default()
1456 .definition(definition)
1457 .max_sessions(0)
1458 .resolve_mob_spec()
1459 .await;
1460 assert!(result.is_err(), "zero max sessions should be rejected");
1461 let err = result.err().expect("zero max sessions error");
1462
1463 assert!(
1464 err.to_string().contains("max_sessions"),
1465 "unexpected error: {err}",
1466 );
1467 }
1468
1469 #[test]
1470 fn builder_accepts_custom_console_log_store_for_ephemeral_mob_state() {
1471 let store: Arc<dyn ConsoleLogStore> = Arc::new(InMemoryConsoleLogStore::new());
1472 let builder = UnifiedRuntimeBuilder::default().with_console_log_store(store.clone());
1473
1474 assert!(
1475 Arc::ptr_eq(
1476 builder.console_log_store.as_ref().expect("custom store"),
1477 &store
1478 ),
1479 "builder should retain the exact console log store supplied by the app"
1480 );
1481 }
1482}