Skip to main content

lash/
core.rs

1use crate::support::*;
2use lash_core::runtime::{
3    ProcessCommand, ProcessEffectOutcome, RuntimeEffectCommand, RuntimeEffectEnvelope,
4    RuntimeEffectKind, RuntimeEffectLocalExecutor, RuntimeEffectOutcome, RuntimeInvocation,
5    RuntimeScope,
6};
7
8#[derive(Clone)]
9pub struct LashCore {
10    pub(crate) env: RuntimeEnvironment,
11    pub(crate) policy: SessionPolicy,
12    pub(crate) protocol_factory: Option<Arc<dyn PluginFactory>>,
13    pub(crate) store_factory: Option<Arc<dyn SessionStoreFactory>>,
14    pub(crate) plugin_factories: Arc<Vec<Arc<dyn PluginFactory>>>,
15    pub(crate) provider: Option<ProviderHandle>,
16    pub(crate) live_replay_store: Arc<dyn LiveReplayStore>,
17    /// Whether this deployment has process lifecycle available (a process
18    /// registry is wired). Threaded to every plugin host so core can install the
19    /// same plugin-contributed process engines when it rebuilds a runtime.
20    pub(crate) process_lifecycle_available: bool,
21    /// Shared resolution of host-owned work drivers. Shared across `LashCore`
22    /// clones so inline process and queued drivers are constructed at most once.
23    pub(crate) work_driver: Arc<InlineWorkDriverSlot>,
24}
25
26/// How a [`LashCore`] resolves its process work driver, decided at `build()`
27/// and shared across clones.
28pub(crate) enum ProcessWorkDriverSetup {
29    /// No process registry is wired; there is nothing to run.
30    None,
31    /// Lazily construct the default inline process driver on first
32    /// `session().open()`. A store factory is required to build the config (the
33    /// worker rebuilds a session runtime per process); a registry with no store
34    /// factory is rejected at build with
35    /// [`EmbedError::ProcessRegistryRequiresStoreFactory`].
36    LazyDefault {
37        config: Box<DurableProcessWorkerConfig>,
38    },
39    /// The host wired an external driver.
40    External { driver: ProcessWorkDriver },
41}
42
43#[derive(Clone, Default)]
44pub(crate) enum ProcessWorkSource {
45    #[default]
46    None,
47    Inline {
48        registry: Arc<dyn ProcessRegistry>,
49    },
50    External(ProcessWorkDriver),
51}
52
53impl ProcessWorkSource {
54    fn process_registry(&self) -> Option<Arc<dyn ProcessRegistry>> {
55        match self {
56            Self::None => None,
57            Self::Inline { registry } => Some(Arc::clone(registry)),
58            Self::External(driver) => Some(driver.process_registry()),
59        }
60    }
61
62    fn has_registry(&self) -> bool {
63        !matches!(self, Self::None)
64    }
65}
66
67#[derive(Clone, Default)]
68pub(crate) enum QueuedWorkSource {
69    None,
70    #[default]
71    LazyDefault,
72    External(QueuedWorkDriver),
73}
74
75pub(crate) enum QueuedWorkDriverSetup {
76    None,
77    LazyDefault {
78        config: Arc<InlineQueuedWorkRunConfig>,
79    },
80    External {
81        driver: QueuedWorkDriver,
82    },
83}
84
85pub(crate) struct InlineWorkDriverSetup {
86    process: ProcessWorkDriverSetup,
87    queued: QueuedWorkDriverSetup,
88}
89
90#[derive(Clone, Default)]
91pub(crate) struct ResolvedWorkDrivers {
92    pub(crate) process: Option<ProcessWorkDriver>,
93    pub(crate) queued: Option<QueuedWorkDriver>,
94    pub(crate) drive_process_on_open: bool,
95}
96
97/// Shared, lazily-initialized host-work state for a [`LashCore`].
98///
99/// The once-guard ([`tokio::sync::OnceCell`]) constructs inline drivers exactly
100/// once across `LashCore` clones, on the first `session().open()` or admin path
101/// that needs them.
102pub(crate) struct InlineWorkDriverSlot {
103    setup: InlineWorkDriverSetup,
104    drivers: tokio::sync::OnceCell<ResolvedWorkDrivers>,
105    phase_probe_slot: Option<lash_core::runtime::RuntimeTurnPhaseProbeSlot>,
106}
107
108impl InlineWorkDriverSlot {
109    fn new(setup: InlineWorkDriverSetup) -> Self {
110        let phase_probe_slot = match &setup.process {
111            ProcessWorkDriverSetup::LazyDefault { config } => {
112                Some(config.turn_phase_probe_slot.clone())
113            }
114            ProcessWorkDriverSetup::None | ProcessWorkDriverSetup::External { .. } => None,
115        };
116        Self {
117            setup,
118            drivers: tokio::sync::OnceCell::new(),
119            phase_probe_slot,
120        }
121    }
122
123    /// Resolve host work drivers for a session host. Idempotent: the once-guard
124    /// ensures inline drivers are constructed once.
125    pub(crate) async fn drivers(&self) -> ResolvedWorkDrivers {
126        self.drivers
127            .get_or_init(|| async {
128                let queued = match &self.setup.queued {
129                    QueuedWorkDriverSetup::None => None,
130                    QueuedWorkDriverSetup::External { driver } => Some(driver.clone()),
131                    QueuedWorkDriverSetup::LazyDefault { config } => Some(QueuedWorkDriver::new(
132                        Arc::new(InlineQueuedWorkRunHandle::new(Arc::clone(config))),
133                    )),
134                };
135                let (process, drive_process_on_open) = match &self.setup.process {
136                    ProcessWorkDriverSetup::None => (None, false),
137                    ProcessWorkDriverSetup::External { driver } => (Some(driver.clone()), false),
138                    ProcessWorkDriverSetup::LazyDefault { config } => {
139                        let mut config = (**config).clone();
140                        if let Some(driver) = queued.clone() {
141                            config = config.with_queued_work_driver(driver);
142                        }
143                        let registry = Arc::clone(&config.process_registry);
144                        let worker = DurableProcessWorker::new(config);
145                        (Some(ProcessWorkDriver::inline(registry, worker)), true)
146                    }
147                };
148                ResolvedWorkDrivers {
149                    process,
150                    queued,
151                    drive_process_on_open,
152                }
153            })
154            .await
155            .clone()
156    }
157
158    pub(crate) fn phase_probe_slot(&self) -> Option<lash_core::runtime::RuntimeTurnPhaseProbeSlot> {
159        self.phase_probe_slot.clone()
160    }
161
162    fn configured_process_work_driver(&self) -> Option<ProcessWorkDriver> {
163        match &self.setup.process {
164            ProcessWorkDriverSetup::External { driver } => Some(driver.clone()),
165            ProcessWorkDriverSetup::None | ProcessWorkDriverSetup::LazyDefault { .. } => None,
166        }
167    }
168
169    fn configured_queued_work_driver(&self) -> Option<QueuedWorkDriver> {
170        match &self.setup.queued {
171            QueuedWorkDriverSetup::External { driver } => Some(driver.clone()),
172            QueuedWorkDriverSetup::None | QueuedWorkDriverSetup::LazyDefault { .. } => None,
173        }
174    }
175}
176
177pub(crate) struct InlineQueuedWorkRunConfig {
178    env: RuntimeEnvironment,
179    policy: SessionPolicy,
180    protocol_factory: Option<Arc<dyn PluginFactory>>,
181    plugin_factories: Arc<Vec<Arc<dyn PluginFactory>>>,
182    store_factory: Arc<dyn SessionStoreFactory>,
183    live_replay_store: Arc<dyn LiveReplayStore>,
184    process_lifecycle_available: bool,
185}
186
187impl InlineQueuedWorkRunConfig {
188    fn new(
189        env: RuntimeEnvironment,
190        policy: SessionPolicy,
191        protocol_factory: Option<Arc<dyn PluginFactory>>,
192        plugin_factories: Arc<Vec<Arc<dyn PluginFactory>>>,
193        store_factory: Arc<dyn SessionStoreFactory>,
194        live_replay_store: Arc<dyn LiveReplayStore>,
195        process_lifecycle_available: bool,
196    ) -> Self {
197        Self {
198            env,
199            policy,
200            protocol_factory,
201            plugin_factories,
202            store_factory,
203            live_replay_store,
204            process_lifecycle_available,
205        }
206    }
207}
208
209struct InlineQueuedWorkRunHandle {
210    config: Arc<InlineQueuedWorkRunConfig>,
211}
212
213impl InlineQueuedWorkRunHandle {
214    fn new(config: Arc<InlineQueuedWorkRunConfig>) -> Self {
215        Self { config }
216    }
217}
218
219#[async_trait]
220impl QueuedWorkRunHandle for InlineQueuedWorkRunHandle {
221    async fn run_queued_work(
222        &self,
223        request: QueuedWorkRunRequest,
224    ) -> std::result::Result<(), lash_core::PluginError> {
225        let Some(session_id) = request.session_id else {
226            return Ok(());
227        };
228        let reason = request.reason;
229        let mut policy = self.config.policy.clone();
230        policy.session_id = Some(session_id.clone());
231        let store = self
232            .config
233            .store_factory
234            .create_store(&SessionStoreCreateRequest {
235                session_id: session_id.clone(),
236                relation: SessionRelation::default(),
237                policy: policy.clone(),
238            })
239            .await
240            .map_err(lash_core::PluginError::Session)?;
241        let state = crate::session::load_state_for_residency(
242            self.config.env.residency,
243            &session_id,
244            &policy,
245            store.as_ref(),
246        )
247        .await
248        .map_err(|err| lash_core::PluginError::Session(err.to_string()))?;
249        let plugin_host = build_plugin_host(
250            self.config.protocol_factory.as_ref(),
251            self.config.plugin_factories.as_ref(),
252            Vec::new(),
253        )
254        .map_err(|err| lash_core::PluginError::Session(err.to_string()))?;
255        let mut env = self.config.env.clone();
256        env.core = plugin_host
257            .install_process_engine_contributions(
258                env.core.clone(),
259                self.config.process_lifecycle_available,
260            )
261            .map_err(|err| lash_core::PluginError::Session(err.to_string()))?;
262        env.plugin_host = Some(Arc::new(plugin_host));
263        let effect_host = Arc::clone(&env.core.control.effect_host);
264        let runtime = LashRuntime::from_environment(&env, policy, state, Some(store))
265            .await
266            .map_err(|err| lash_core::PluginError::Session(err.to_string()))?;
267        let handle = RuntimeHandle::with_live_replay_store(
268            runtime,
269            Arc::clone(&self.config.live_replay_store),
270        );
271        let scope = lash_core::ExecutionScope::queue_drain(session_id, reason);
272        let scoped = effect_host
273            .scoped(scope)
274            .map_err(|err| lash_core::PluginError::Session(err.to_string()))?;
275        crate::turn::stream_next_queued_prepared_turn(
276            &handle,
277            crate::turn::TurnSinks::default(),
278            scoped,
279            CancellationToken::new(),
280            &[],
281        )
282        .await
283        .map_err(|err| lash_core::PluginError::Session(err.to_string()))?;
284        Ok(())
285    }
286}
287
288#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
289pub struct SessionDeleteReport {
290    pub session_id: String,
291    pub process: Option<lash_core::ProcessSessionDeleteReport>,
292}
293
294impl LashCore {
295    pub fn builder() -> LashCoreBuilder {
296        LashCoreBuilder::default()
297    }
298
299    /// Sugar entry point: a [`LashCoreBuilder`] pre-seeded with the standard
300    /// protocol plugin and the default runtime plugin stack.
301    pub fn standard_builder() -> LashCoreBuilder {
302        LashCore::builder()
303            .protocol_plugin(Arc::new(
304                lash_protocol_standard::StandardProtocolPluginFactory::new(),
305            ))
306            .plugins(default_runtime_stack())
307    }
308
309    /// Sugar entry point: a [`LashCoreBuilder`] pre-seeded with a
310    /// host-configured RLM protocol factory and the default runtime plugin
311    /// stack.
312    ///
313    /// The host configures the factory (projection resolver, deferred tool
314    /// resolver, execution sink/jsonl path, and — required at construction — the
315    /// Lashlang artifact store) before passing it in.
316    #[cfg(feature = "rlm")]
317    pub fn rlm_builder(factory: crate::rlm::RlmProtocolPluginFactory) -> LashCoreBuilder {
318        LashCore::builder()
319            .protocol_plugin(Arc::new(factory))
320            .plugins(default_runtime_stack())
321    }
322
323    pub fn session(&self, session_id: impl Into<String>) -> SessionBuilder {
324        SessionBuilder {
325            core: self.clone(),
326            session_id: session_id.into(),
327            spec: SessionSpec::inherit(),
328            parent_session_id: None,
329            session_execution_owner: None,
330            store: None,
331            provider: None,
332            active_plugins: Vec::new(),
333            plugin_factories: Vec::new(),
334            plugin_options: PluginOptions::default(),
335        }
336    }
337
338    /// Rebuild a live session from a [`ParkedSession`](crate::ParkedSession)
339    /// handle produced by [`LashSession::park`](crate::LashSession::park).
340    ///
341    /// Resume reloads the flushed state from the parked store (honoring this
342    /// core's residency), reinstalls this core's plugin configuration and work
343    /// drivers, and returns a ready [`LashSession`]. The parked store instance
344    /// is reused directly, so the transcript the session flushed at park time is
345    /// visible again after resume.
346    ///
347    /// This restores the core-level plugin stack. Session-specific plugins added
348    /// per open via [`SessionBuilder::plugin`] are not re-applied here; parking
349    /// is the round-trip for the core's own configuration.
350    pub async fn resume(&self, parked: ParkedSession) -> Result<LashSession> {
351        // Build the per-session env exactly like `SessionBuilder::open_resolved`
352        // (minus builder-scoped plugins): a fresh plugin host with this core's
353        // factories, the shared work drivers, and the core provider resolver
354        // already carried on `self.env`.
355        let plugin_host = build_plugin_host(
356            self.protocol_factory.as_ref(),
357            self.plugin_factories.as_ref(),
358            Vec::new(),
359        )?;
360        let mut env = self.env.clone();
361        env.core = plugin_host.install_process_engine_contributions(
362            env.core.clone(),
363            self.process_lifecycle_available,
364        )?;
365        env.plugin_host = Some(Arc::new(plugin_host));
366        let effect_host = Arc::clone(&env.core.control.effect_host);
367        let drivers = self.work_driver.drivers().await;
368        env.process_work_driver = drivers.process.clone();
369        env.queued_work_driver = drivers.queued.clone();
370        let runtime = LashRuntime::resume(parked.inner, &env).await?;
371        let handle =
372            RuntimeHandle::with_live_replay_store(runtime, Arc::clone(&self.live_replay_store));
373        Ok(LashSession {
374            runtime: handle,
375            effect_host,
376            parent_session_id: None,
377            active_plugins: Vec::new(),
378            process_phase_probe_slot: self.work_driver.phase_probe_slot(),
379            turn_cancels: crate::turn::TurnCancelRegistry::default(),
380        })
381    }
382
383    /// Flush this core's configured trace sink, if any.
384    ///
385    /// Hosts that hand `lash` a trace sink via
386    /// [`LashCoreBuilder::trace_sink`] already hold their own `Arc` and can
387    /// flush it directly; this is the equivalent lever for hosts that did not
388    /// retain the handle. It flushes the core's copy — for a
389    /// [`JsonlTraceSink`](lash_trace::JsonlTraceSink) that fsyncs the file, and
390    /// for an OTel sink it is a no-op (the host still owns provider flush; see
391    /// the tracing docs). Call it before process exit alongside the host's own
392    /// exporter/provider shutdown.
393    pub fn flush_trace_sink(&self) -> Result<()> {
394        if let Some(sink) = self.env.core.tracing.trace_sink.as_ref() {
395            sink.flush()?;
396        }
397        Ok(())
398    }
399
400    pub fn triggers(&self) -> crate::admin::CoreTriggerAdmin {
401        crate::admin::CoreTriggerAdmin { core: self.clone() }
402    }
403
404    pub fn processes(&self) -> crate::admin::Processes {
405        crate::admin::Processes { core: self.clone() }
406    }
407
408    pub fn completions(&self) -> crate::admin::Completions {
409        crate::admin::Completions { core: self.clone() }
410    }
411
412    pub fn effect_host(&self) -> Arc<dyn EffectHost> {
413        Arc::clone(&self.env.core.control.effect_host)
414    }
415
416    pub async fn delete_session(
417        &self,
418        session_id: impl AsRef<str>,
419        scoped_effect_controller: ScopedEffectController<'_>,
420    ) -> Result<SessionDeleteReport> {
421        let session_id = session_id.as_ref().to_string();
422        let Some(store_factory) = self.store_factory.as_ref() else {
423            return Err(EmbedError::MissingSessionStoreFactory);
424        };
425        let process = if let Some(process_registry) = self.env.process_registry.as_ref() {
426            let invocation = RuntimeInvocation::effect(
427                RuntimeScope::new(session_id.clone()),
428                format!("process:delete-session:{session_id}"),
429                RuntimeEffectKind::Process,
430                format!("{session_id}:delete-session"),
431            );
432            let outcome = scoped_effect_controller
433                .controller()
434                .execute_effect(
435                    RuntimeEffectEnvelope::new(
436                        invocation,
437                        RuntimeEffectCommand::process(ProcessCommand::DeleteSession {
438                            session_id: session_id.clone(),
439                        }),
440                    ),
441                    RuntimeEffectLocalExecutor::processes(Arc::clone(process_registry)),
442                )
443                .await
444                .map_err(|err| EmbedError::SessionDeleteProcess {
445                    session_id: session_id.clone(),
446                    message: err.to_string(),
447                })?;
448            match outcome {
449                RuntimeEffectOutcome::Process {
450                    result: ProcessEffectOutcome::DeleteSession { report },
451                } => Some(report),
452                other => {
453                    return Err(EmbedError::SessionDeleteProcess {
454                        session_id,
455                        message: format!(
456                            "process delete returned the wrong outcome: {}",
457                            other.kind().as_str()
458                        ),
459                    });
460                }
461            }
462        } else {
463            None
464        };
465        if let Some(trigger_store) = self.env.trigger_store.as_ref() {
466            trigger_store
467                .delete_session_subscriptions(&session_id)
468                .await
469                .map_err(|err| EmbedError::SessionDeleteProcess {
470                    session_id: session_id.clone(),
471                    message: err.to_string(),
472                })?;
473        }
474        self.env
475            .core
476            .control
477            .effect_host
478            .revoke_await_events_for_session(&session_id)
479            .await
480            .map_err(|err| EmbedError::SessionDeleteProcess {
481                session_id: session_id.clone(),
482                message: err.to_string(),
483            })?;
484        store_factory
485            .delete_session(&session_id)
486            .await
487            .map_err(|message| EmbedError::StoreFactory {
488                session_id: session_id.clone(),
489                message,
490            })?;
491        Ok(SessionDeleteReport {
492            session_id,
493            process,
494        })
495    }
496
497    pub fn process_registry(&self) -> Option<Arc<dyn ProcessRegistry>> {
498        self.env.process_registry.as_ref().cloned()
499    }
500
501    pub fn durable_process_worker_config(&self) -> Result<DurableProcessWorkerConfig> {
502        self.durable_process_worker_config_with_plugins(std::iter::empty::<Arc<dyn PluginFactory>>())
503    }
504
505    pub fn durable_process_worker_config_with_plugins(
506        &self,
507        extra_plugin_factories: impl IntoIterator<Item = Arc<dyn PluginFactory>>,
508    ) -> Result<DurableProcessWorkerConfig> {
509        let Some(process_registry) = self.process_registry() else {
510            return Err(EmbedError::MissingProcessRegistry);
511        };
512        let Some(store_factory) = self.store_factory.as_ref() else {
513            return Err(EmbedError::MissingProcessWorkerStoreFactory);
514        };
515        let plugin_host = build_plugin_host(
516            self.protocol_factory.as_ref(),
517            self.plugin_factories.as_ref(),
518            extra_plugin_factories.into_iter().collect(),
519        )?;
520        let runtime_host = plugin_host.install_process_engine_contributions(
521            self.env.core.clone(),
522            self.process_lifecycle_available,
523        )?;
524        let mut config = DurableProcessWorkerConfig::new(
525            Arc::new(plugin_host),
526            runtime_host,
527            Arc::clone(store_factory),
528            process_registry,
529        )
530        .with_session_policy(self.policy.clone())
531        .with_residency(self.env.residency);
532        if let Some(trigger_store) = self.env.trigger_store.as_ref() {
533            config = config.with_trigger_store(Arc::clone(trigger_store));
534        }
535        if let Some(driver) = self.work_driver.configured_process_work_driver() {
536            config = config.with_process_work_driver(driver);
537        }
538        if let Some(driver) = self.work_driver.configured_queued_work_driver() {
539            config = config.with_queued_work_driver(driver);
540        }
541        Ok(config)
542    }
543}
544
545fn default_runtime_stack() -> PluginStack {
546    lash_plugin_tool_output_budget::tool_output_budget_stack()
547}
548
549#[derive(Default)]
550pub struct LashCoreBuilder {
551    pub(crate) protocol_factory: Option<Arc<dyn PluginFactory>>,
552    session_spec: SessionSpec,
553    provider: Option<ProviderHandle>,
554    pub(crate) store_factory: Option<Arc<dyn SessionStoreFactory>>,
555    child_store_factory: Option<Arc<dyn SessionStoreFactory>>,
556    // `RuntimeHostConfig` has no `Default`: the generic host-owned durability
557    // dependencies must be named. They are collected here and resolved in
558    // `build()`, which errors if any is unset.
559    effect_host: Option<Arc<dyn EffectHost>>,
560    attachment_store: Option<Arc<dyn AttachmentStore>>,
561    process_env_store: Option<Arc<dyn ProcessExecutionEnvStore>>,
562    trigger_store: Option<Arc<dyn lash_core::TriggerStore>>,
563    // Benign core overrides applied on top of the resolved core.
564    prompt: Option<PromptLayer>,
565    trace_sink: Option<Arc<dyn lash_trace::TraceSink>>,
566    trace_level: Option<lash_trace::TraceLevel>,
567    trace_context: Option<lash_trace::TraceContext>,
568    termination: Option<TerminationPolicy>,
569    // Advanced full-config override; used as the base core when present.
570    runtime_host_config: Option<RuntimeHostConfig>,
571    tool_providers: Vec<Arc<dyn ToolProvider>>,
572    plugin_stack: PluginStack,
573    plugin_host: Option<PluginHost>,
574    residency: Option<Residency>,
575    lease_timings: Option<lash_core::LeaseTimings>,
576    // Single source of truth for process lifecycle support and process-work
577    // consumption.
578    process_work_source: ProcessWorkSource,
579    queued_work_source: QueuedWorkSource,
580    live_replay_store: Option<Arc<dyn LiveReplayStore>>,
581}
582
583impl LashCoreBuilder {
584    pub fn protocol_plugin(mut self, plugin: Arc<dyn PluginFactory>) -> Self {
585        self.protocol_factory = Some(plugin);
586        self
587    }
588
589    pub fn provider(mut self, provider: ProviderHandle) -> Self {
590        self.session_spec = self.session_spec.provider_id(provider.kind());
591        self.provider = Some(provider);
592        self
593    }
594
595    pub fn model(mut self, model: lash_core::ModelSpec) -> Self {
596        self.session_spec = self.session_spec.model(model);
597        self
598    }
599
600    pub fn max_turns(mut self, max_turns: usize) -> Self {
601        self.session_spec = self.session_spec.max_turns(max_turns);
602        self
603    }
604
605    pub fn session_spec(mut self, spec: SessionSpec) -> Self {
606        self.session_spec = spec;
607        self
608    }
609
610    /// Configure a factory that can create a persistence store for any root
611    /// session opened from this core.
612    ///
613    /// The factory must honor `SessionStoreCreateRequest::session_id` and
614    /// return a store for that specific session. Do not use this to wrap one
615    /// pre-opened root store; pass root-only stores with
616    /// `LashCore::session(...).store(store)` instead.
617    pub fn store_factory(mut self, store_factory: Arc<dyn SessionStoreFactory>) -> Self {
618        self.store_factory = Some(store_factory);
619        self
620    }
621
622    /// Configure the persistence factory used by managed child sessions, such
623    /// as local subagents.
624    ///
625    /// Child factories must return a distinct store bound to the requested
626    /// child session id. Hosts that pass an explicit root store with
627    /// `SessionBuilder::store` should set this when child sessions need
628    /// persistence.
629    pub fn child_store_factory(mut self, store_factory: Arc<dyn SessionStoreFactory>) -> Self {
630        self.child_store_factory = Some(store_factory);
631        self
632    }
633
634    pub fn attachment_store(mut self, attachment_store: Arc<dyn AttachmentStore>) -> Self {
635        self.attachment_store = Some(attachment_store);
636        self
637    }
638
639    pub fn process_env_store(
640        mut self,
641        process_env_store: Arc<dyn ProcessExecutionEnvStore>,
642    ) -> Self {
643        self.process_env_store = Some(process_env_store);
644        self
645    }
646
647    /// Set the deployment effect host — the durability boundary every operation
648    /// crosses. Pass [`InlineEffectHost`](crate::durability::InlineEffectHost)
649    /// for in-process execution, or a workflow-backed host for durable
650    /// execution.
651    pub fn effect_host(mut self, effect_host: Arc<dyn EffectHost>) -> Self {
652        self.effect_host = Some(effect_host);
653        self
654    }
655
656    pub fn tools(mut self, tools: Arc<dyn ToolProvider>) -> Self {
657        self.tool_providers.push(tools);
658        self
659    }
660
661    pub fn plugin(mut self, plugin: Arc<dyn PluginFactory>) -> Self {
662        self.plugin_stack.push(plugin);
663        self
664    }
665
666    pub fn plugins(mut self, stack: PluginStack) -> Self {
667        self.plugin_stack = stack;
668        self
669    }
670
671    pub fn configure_plugins(mut self, configure: impl FnOnce(&mut PluginStack)) -> Self {
672        configure(&mut self.plugin_stack);
673        self
674    }
675
676    pub fn trace_sink(mut self, trace_sink: Arc<dyn lash_trace::TraceSink>) -> Self {
677        self.trace_sink = Some(trace_sink);
678        self
679    }
680
681    pub fn trace_jsonl_path(mut self, path: impl Into<std::path::PathBuf>) -> Self {
682        self.trace_sink = Some(Arc::new(lash_trace::JsonlTraceSink::new(path.into())));
683        self
684    }
685
686    pub fn trace_level(mut self, trace_level: lash_trace::TraceLevel) -> Self {
687        self.trace_level = Some(trace_level);
688        self
689    }
690
691    pub fn trace_context(mut self, trace_context: lash_trace::TraceContext) -> Self {
692        self.trace_context = Some(trace_context);
693        self
694    }
695
696    pub fn termination(mut self, termination: TerminationPolicy) -> Self {
697        self.termination = Some(termination);
698        self
699    }
700
701    pub fn residency(mut self, residency: Residency) -> Self {
702        self.residency = Some(residency);
703        self
704    }
705
706    /// Configure the lease timing capability for every durable single-writer
707    /// lane this deployment claims: session execution leases, turn-input and
708    /// queued-work claims, and process leases.
709    ///
710    /// This is the failover-latency vs false-takeover-risk knob. Like
711    /// [`residency`](Self::residency) it is an operational deployment decision,
712    /// so it lives on the main builder tier rather than behind
713    /// [`advanced`](Self::advanced). Construct the value with
714    /// [`LeaseTimings::new`](lash_core::LeaseTimings::new), which enforces
715    /// `ttl >= 3 * renew_interval`. Effect hosts accept the same type at
716    /// construction (e.g. SQLite/Postgres effect-replay options), so a host can
717    /// share one timing decision across both boundaries.
718    pub fn lease_timings(mut self, lease_timings: lash_core::LeaseTimings) -> Self {
719        self.lease_timings = Some(lease_timings);
720        self
721    }
722
723    /// Configure the bounded live replay buffer used by session observation
724    /// cursors. This is best-effort reconnect recovery only; durable state
725    /// still comes from the session store and [`SessionReadView`].
726    pub fn live_replay_store(mut self, live_replay_store: Arc<dyn LiveReplayStore>) -> Self {
727        self.live_replay_store = Some(live_replay_store);
728        self
729    }
730
731    /// Resolve the runtime host config, requiring the generic host-owned
732    /// durability dependencies to have been named.
733    fn resolve_runtime_host_config(&mut self) -> Result<RuntimeHostConfig> {
734        if let Some(base) = self.runtime_host_config.take() {
735            return Ok(self.apply_core_overrides(base));
736        }
737        let effect_host = self
738            .effect_host
739            .take()
740            .ok_or(EmbedError::MissingEffectHost)?;
741        let attachment_store = self
742            .attachment_store
743            .take()
744            .ok_or(EmbedError::MissingAttachmentStore)?;
745        let process_env_store = self
746            .process_env_store
747            .take()
748            .ok_or(EmbedError::MissingProcessEnvStore)?;
749        let core = RuntimeHostConfig::new(effect_host, attachment_store, process_env_store);
750        Ok(self.apply_core_overrides(core))
751    }
752
753    /// Apply benign + still-set dependency overrides on top of a base core.
754    fn apply_core_overrides(&mut self, mut core: RuntimeHostConfig) -> RuntimeHostConfig {
755        if let Some(effect_host) = self.effect_host.take() {
756            core.control.effect_host = effect_host;
757        }
758        if let Some(attachment_store) = self.attachment_store.take() {
759            core.durability.attachment_store = attachment_store;
760        }
761        if let Some(process_env_store) = self.process_env_store.take() {
762            core.durability.process_env_store = process_env_store;
763        }
764        if let Some(prompt) = self.prompt.take() {
765            core.prompt.prompt = prompt;
766        }
767        if let Some(trace_sink) = self.trace_sink.take() {
768            core.tracing.trace_sink = Some(trace_sink);
769        }
770        if let Some(trace_level) = self.trace_level.take() {
771            core.tracing.trace_level = trace_level;
772        }
773        if let Some(trace_context) = self.trace_context.take() {
774            core.tracing.trace_context = trace_context;
775        }
776        if let Some(termination) = self.termination.take() {
777            core.control.termination = termination;
778        }
779        if let Some(lease_timings) = self.lease_timings.take() {
780            core.control.lease_timings = lease_timings;
781        }
782        core
783    }
784
785    /// Validate store peer-coherence of the wired durability dependencies.
786    ///
787    /// Durability is established by what the host wired; the per-invocation
788    /// durable controller is not visible here (the build-time controller is
789    /// inline by construction), so this checks the stores against each other
790    /// only — never the controller (see A5 in the durable-first wiring spec):
791    ///
792    /// - a durable session store factory requires a durable attachment and
793    ///   artifact store (they back the same session state);
794    /// - a durable process registry requires a session store factory that is
795    ///   itself durable (the registry's process records are meaningless without
796    ///   a durable session behind them).
797    fn effective_session_store_tier(&self) -> Option<DurabilityTier> {
798        self.child_store_factory
799            .as_ref()
800            .or(self.store_factory.as_ref())
801            .map(|factory| factory.durability_tier())
802    }
803
804    /// Validate store peer-coherence, sweeping every registered process engine.
805    ///
806    /// Runs after the runtime host is resolved and process-engine contributions
807    /// are installed, so it reads the effective effect host / attachment /
808    /// process-env tiers off `core`, and the session-store / trigger-store /
809    /// process-registry tiers captured from the builder before its plugin stack
810    /// was consumed. A durable session store requires every registered engine to
811    /// be durable too.
812    fn ensure_store_peer_coherence(
813        session_store_tier: Option<DurabilityTier>,
814        trigger_store_tier: Option<DurabilityTier>,
815        process_registry_tier: Option<DurabilityTier>,
816        core: &RuntimeHostConfig,
817    ) -> Result<()> {
818        let attachment_tier = Some(
819            core.durability
820                .attachment_store
821                .persistence()
822                .durability_tier(),
823        );
824        let process_env_tier = Some(core.durability.process_env_store.durability_tier());
825        let effect_host_tier = Some(core.control.effect_host.durability_tier());
826
827        if session_store_tier == Some(DurabilityTier::Durable) {
828            if attachment_tier == Some(DurabilityTier::Inline) {
829                return Err(EmbedError::DurableStorePeerRequired {
830                    facet: "attachment store",
831                });
832            }
833            if process_env_tier == Some(DurabilityTier::Inline) {
834                return Err(EmbedError::DurableStorePeerRequired {
835                    facet: "process execution environment store",
836                });
837            }
838            // Every registered process engine must be durable behind a durable
839            // session store, regardless of how it was contributed.
840            for engine in core.process_engines.engines() {
841                if engine.durability_tier() == DurabilityTier::Inline {
842                    return Err(EmbedError::DurableStorePeerRequired {
843                        facet: engine.kind(),
844                    });
845                }
846            }
847        }
848
849        if process_registry_tier == Some(DurabilityTier::Durable) {
850            if session_store_tier != Some(DurabilityTier::Durable) {
851                return Err(EmbedError::DurableProcessRegistryRequiresStoreFactory);
852            }
853            if trigger_store_tier != Some(DurabilityTier::Durable) {
854                return Err(EmbedError::DurableStorePeerRequired {
855                    facet: "trigger store",
856                });
857            }
858            if process_env_tier != Some(DurabilityTier::Durable) {
859                return Err(EmbedError::DurableStorePeerRequired {
860                    facet: "process execution environment store",
861                });
862            }
863        }
864
865        if trigger_store_tier == Some(DurabilityTier::Durable) {
866            if session_store_tier != Some(DurabilityTier::Durable) {
867                return Err(EmbedError::DurableStorePeerRequired {
868                    facet: "session store factory",
869                });
870            }
871            if process_env_tier != Some(DurabilityTier::Durable) {
872                return Err(EmbedError::DurableStorePeerRequired {
873                    facet: "process execution environment store",
874                });
875            }
876            if process_registry_tier == Some(DurabilityTier::Inline) {
877                return Err(EmbedError::DurableStorePeerRequired {
878                    facet: "process registry",
879                });
880            }
881        }
882
883        if effect_host_tier == Some(DurabilityTier::Durable) {
884            if attachment_tier != Some(DurabilityTier::Durable) {
885                return Err(EmbedError::DurableStorePeerRequired {
886                    facet: "attachment store",
887                });
888            }
889            if process_env_tier != Some(DurabilityTier::Durable) {
890                return Err(EmbedError::DurableStorePeerRequired {
891                    facet: "process execution environment store",
892                });
893            }
894        }
895
896        Ok(())
897    }
898
899    pub fn build(mut self) -> Result<LashCore> {
900        let protocol_factory = self.protocol_factory.clone();
901        if protocol_factory.is_none() && self.plugin_host.is_none() {
902            return Err(EmbedError::MissingProtocolPlugin);
903        }
904        let provider_id = self
905            .session_spec
906            .provider_id
907            .clone()
908            .or_else(|| {
909                self.provider
910                    .as_ref()
911                    .map(|provider| provider.kind().to_string())
912            })
913            .unwrap_or_default();
914        let model = self
915            .session_spec
916            .model
917            .clone()
918            .ok_or(EmbedError::MissingModelSpec)?;
919
920        let base_policy = SessionPolicy {
921            provider_id,
922            model,
923            max_turns: self.session_spec.max_turns.flatten(),
924            ..SessionPolicy::default()
925        };
926        let policy = self.session_spec.resolve_against(&base_policy);
927
928        // Capture the store-peer tiers that live on builder fields before the
929        // plugin stack is consumed below; the engine sweep happens after install.
930        let session_store_tier = self.effective_session_store_tier();
931        let trigger_store_tier = self
932            .trigger_store
933            .as_ref()
934            .map(|store| store.durability_tier());
935        let process_registry_tier = self
936            .process_work_source
937            .process_registry()
938            .map(|registry| registry.durability_tier());
939
940        let mut core = self.resolve_runtime_host_config()?;
941        if let Some(provider) = self.provider.clone() {
942            core.providers.provider_resolver =
943                Arc::new(lash_core::SingleProviderResolver::new(provider));
944        }
945        let plugin_factories = if let Some(plugin_host) = self.plugin_host {
946            plugin_host.factories().to_vec()
947        } else {
948            let mut factories = Vec::new();
949            if !self.tool_providers.is_empty() {
950                let spec = self
951                    .tool_providers
952                    .into_iter()
953                    .fold(PluginSpec::new(), PluginSpec::with_tool_provider);
954                factories.push(Arc::new(StaticPluginFactory::new("embed_tools", spec))
955                    as Arc<dyn PluginFactory>);
956            }
957            factories.extend(self.plugin_stack.into_factories());
958            factories
959        };
960        let default_plugin_host =
961            build_plugin_host(protocol_factory.as_ref(), &plugin_factories, Vec::new())?;
962        // Whether process lifecycle is available (a process registry is wired).
963        // Threaded to every plugin host so core installs the same
964        // plugin-contributed process engines wherever it rebuilds a runtime.
965        let process_lifecycle_available = self.process_work_source.has_registry();
966        // Install onto a throwaway clone purely to sweep every registered
967        // engine's durability tier for the coherence check. `env.core` stays
968        // free of plugin-contributed engines so that each runtime-construction
969        // site (session open, queued-work drain, durable process worker)
970        // installs them fresh onto a clean registry — keeping the unique-kind
971        // enforcement in `try_with_engine` a genuine cross-factory check rather
972        // than a self-collision on a registry that already carries them.
973        let core_with_engines = default_plugin_host
974            .install_process_engine_contributions(core.clone(), process_lifecycle_available)?;
975        // Coherence runs after engines are installed so it can sweep every
976        // registered engine's durability tier.
977        Self::ensure_store_peer_coherence(
978            session_store_tier,
979            trigger_store_tier,
980            process_registry_tier,
981            &core_with_engines,
982        )?;
983
984        let process_registry = self.process_work_source.process_registry();
985
986        // Resolve process work before the process source is moved into the
987        // environment. The default inline driver's config is built
988        // eagerly so a missing store factory fails loudly at build, not at
989        // first open. It is built from the same single-protocol plugin host the
990        // live runtime uses, so the worker can rebuild a runtime for a process.
991        let process_work_driver = Self::resolve_process_work_driver(
992            &self.process_work_source,
993            &default_plugin_host,
994            &core,
995            process_lifecycle_available,
996            // The worker rebuilds sessions with the same factory `build()` wires
997            // below: `child_store_factory.or(store_factory)`.
998            self.child_store_factory
999                .as_ref()
1000                .or(self.store_factory.as_ref()),
1001            &policy,
1002            self.residency.unwrap_or_default(),
1003            self.trigger_store.as_ref(),
1004        )?;
1005
1006        let live_replay_clock = Arc::clone(&core.clock);
1007        let mut env_builder = RuntimeEnvironment::builder()
1008            .with_plugin_host(Arc::new(default_plugin_host))
1009            .with_runtime_host_config(core);
1010        if let Some(process_registry) = process_registry.as_ref() {
1011            env_builder = env_builder.with_process_registry(Arc::clone(process_registry));
1012        }
1013        if let Some(residency) = self.residency {
1014            env_builder = env_builder.with_residency(residency);
1015        }
1016        if let Some(child_store_factory) = self
1017            .child_store_factory
1018            .as_ref()
1019            .or(self.store_factory.as_ref())
1020        {
1021            env_builder = env_builder.with_session_store_factory(Arc::clone(child_store_factory));
1022        }
1023        if let Some(trigger_store) = self.trigger_store.as_ref() {
1024            env_builder = env_builder.with_trigger_store(Arc::clone(trigger_store));
1025        }
1026        let live_replay_store = self.live_replay_store.take().unwrap_or_else(|| {
1027            Arc::new(InMemoryLiveReplayStore::with_clock(
1028                lash_core::InMemoryLiveReplayStoreConfig::default(),
1029                live_replay_clock,
1030            ))
1031        });
1032        let env = env_builder.build();
1033        let queued_work_driver = Self::resolve_queued_work_driver(
1034            &self.queued_work_source,
1035            env.clone(),
1036            policy.clone(),
1037            protocol_factory.clone(),
1038            Arc::new(plugin_factories.clone()),
1039            self.child_store_factory
1040                .as_ref()
1041                .or(self.store_factory.as_ref()),
1042            Arc::clone(&live_replay_store),
1043            process_lifecycle_available,
1044        );
1045        let work_driver = InlineWorkDriverSetup {
1046            process: process_work_driver,
1047            queued: queued_work_driver,
1048        };
1049
1050        Ok(LashCore {
1051            env,
1052            policy,
1053            store_factory: self.store_factory,
1054            plugin_factories: Arc::new(plugin_factories),
1055            provider: self.provider,
1056            live_replay_store,
1057            protocol_factory,
1058            process_lifecycle_available,
1059            work_driver: Arc::new(InlineWorkDriverSlot::new(work_driver)),
1060        })
1061    }
1062
1063    /// Decide how a built [`LashCore`] sources its process work driver.
1064    ///
1065    /// - no registry => nothing to run ([`ProcessWorkDriverSetup::None`]);
1066    /// - external driver wired => use it ([`ProcessWorkDriverSetup::External`]);
1067    /// - inline registry wired => lazily construct the default inline driver on first open. Its
1068    ///   [`DurableProcessWorkerConfig`] is built eagerly when a store factory is
1069    ///   present; without one the inline worker cannot rebuild session runtimes.
1070    // Mirrors the sibling `resolve_queued_work_driver`: a builder helper whose
1071    // inputs are the heterogeneous, all-required driver-resolution state and
1072    // have no cohesive sub-grouping.
1073    #[allow(clippy::too_many_arguments)]
1074    fn resolve_process_work_driver(
1075        process_work_source: &ProcessWorkSource,
1076        worker_plugin_host: &PluginHost,
1077        core: &RuntimeHostConfig,
1078        process_lifecycle_available: bool,
1079        store_factory: Option<&Arc<dyn SessionStoreFactory>>,
1080        policy: &SessionPolicy,
1081        residency: lash_core::Residency,
1082        trigger_store: Option<&Arc<dyn lash_core::TriggerStore>>,
1083    ) -> Result<ProcessWorkDriverSetup> {
1084        let process_registry = match process_work_source {
1085            ProcessWorkSource::None => return Ok(ProcessWorkDriverSetup::None),
1086            ProcessWorkSource::External(driver) => {
1087                return Ok(ProcessWorkDriverSetup::External {
1088                    driver: driver.clone(),
1089                });
1090            }
1091            ProcessWorkSource::Inline { registry } => Arc::clone(registry),
1092        };
1093        // The worker rebuilds a session runtime per process, so it needs a store
1094        // factory; without one the default runner could not execute anything, so
1095        // fail loudly rather than silently leave processes unexecuted.
1096        let Some(store_factory) = store_factory else {
1097            return Err(EmbedError::ProcessRegistryRequiresStoreFactory);
1098        };
1099        // The worker rebuilds with the same plugin host the live runtime uses,
1100        // including the protocol plugin that supplies the protocol session
1101        // capability. Install its plugin-contributed process engines onto a
1102        // clean copy of the base host — `core` deliberately carries none.
1103        let runtime_host = worker_plugin_host
1104            .install_process_engine_contributions(core.clone(), process_lifecycle_available)?;
1105        let phase_probe_slot = lash_core::runtime::RuntimeTurnPhaseProbeSlot::default();
1106        let config = Box::new(
1107            DurableProcessWorkerConfig::new(
1108                Arc::new(worker_plugin_host.clone()),
1109                runtime_host,
1110                Arc::clone(store_factory),
1111                process_registry,
1112            )
1113            .with_session_policy(policy.clone())
1114            .with_trigger_store(trigger_store.cloned().unwrap_or_else(|| {
1115                Arc::new(lash_core::InMemoryTriggerStore::with_clock(Arc::clone(
1116                    &core.clock,
1117                )))
1118            }))
1119            .with_residency(residency)
1120            .with_turn_phase_probe_slot(phase_probe_slot),
1121        );
1122        Ok(ProcessWorkDriverSetup::LazyDefault { config })
1123    }
1124
1125    #[allow(clippy::too_many_arguments)]
1126    fn resolve_queued_work_driver(
1127        queued_work_source: &QueuedWorkSource,
1128        env: RuntimeEnvironment,
1129        policy: SessionPolicy,
1130        protocol_factory: Option<Arc<dyn PluginFactory>>,
1131        plugin_factories: Arc<Vec<Arc<dyn PluginFactory>>>,
1132        store_factory: Option<&Arc<dyn SessionStoreFactory>>,
1133        live_replay_store: Arc<dyn LiveReplayStore>,
1134        process_lifecycle_available: bool,
1135    ) -> QueuedWorkDriverSetup {
1136        match queued_work_source {
1137            QueuedWorkSource::None => QueuedWorkDriverSetup::None,
1138            QueuedWorkSource::External(driver) => QueuedWorkDriverSetup::External {
1139                driver: driver.clone(),
1140            },
1141            QueuedWorkSource::LazyDefault => match store_factory {
1142                Some(store_factory) => QueuedWorkDriverSetup::LazyDefault {
1143                    config: Arc::new(InlineQueuedWorkRunConfig::new(
1144                        env,
1145                        policy,
1146                        protocol_factory,
1147                        plugin_factories,
1148                        Arc::clone(store_factory),
1149                        live_replay_store,
1150                        process_lifecycle_available,
1151                    )),
1152                },
1153                None => QueuedWorkDriverSetup::None,
1154            },
1155        }
1156    }
1157
1158    pub fn advanced(self) -> AdvancedLashCoreBuilder {
1159        AdvancedLashCoreBuilder { builder: self }
1160    }
1161
1162    pub fn process_registry(mut self, process_registry: Arc<dyn ProcessRegistry>) -> Self {
1163        self.process_work_source = ProcessWorkSource::Inline {
1164            registry: process_registry,
1165        };
1166        self
1167    }
1168
1169    pub fn trigger_store(mut self, store: Arc<dyn lash_core::TriggerStore>) -> Self {
1170        self.trigger_store = Some(store);
1171        self
1172    }
1173
1174    /// Configure an externally owned process work runner.
1175    ///
1176    /// Durable hosts construct a [`ProcessWorkDriver`] from the same process
1177    /// registry and wake handle used by their deployment runner, then pass it
1178    /// here. The driver registry becomes the core's process registry and no
1179    /// inline runner is spawned.
1180    pub fn process_work_driver(mut self, driver: ProcessWorkDriver) -> Self {
1181        self.process_work_source = ProcessWorkSource::External(driver);
1182        self
1183    }
1184
1185    /// Configure an externally owned queued-work driver.
1186    pub fn queued_work_driver(mut self, driver: QueuedWorkDriver) -> Self {
1187        self.queued_work_source = QueuedWorkSource::External(driver);
1188        self
1189    }
1190
1191    pub fn disable_queued_work_driver(mut self) -> Self {
1192        self.queued_work_source = QueuedWorkSource::None;
1193        self
1194    }
1195}
1196
1197pub(crate) fn build_plugin_host(
1198    protocol_factory: Option<&Arc<dyn PluginFactory>>,
1199    common_factories: &[Arc<dyn PluginFactory>],
1200    extra_factories: Vec<Arc<dyn PluginFactory>>,
1201) -> Result<PluginHost> {
1202    let mut factories = Vec::with_capacity(
1203        usize::from(protocol_factory.is_some()) + common_factories.len() + extra_factories.len(),
1204    );
1205    if let Some(protocol_factory) = protocol_factory {
1206        factories.push(Arc::clone(protocol_factory));
1207    }
1208    factories.extend(common_factories.iter().cloned());
1209    factories.extend(extra_factories);
1210    Ok(PluginHost::new(factories))
1211}
1212
1213impl PromptLayerSink for LashCoreBuilder {
1214    fn prompt_layer_mut(&mut self) -> &mut PromptLayer {
1215        self.prompt.get_or_insert_with(PromptLayer::new)
1216    }
1217}
1218
1219pub struct AdvancedLashCoreBuilder {
1220    builder: LashCoreBuilder,
1221}
1222
1223impl AdvancedLashCoreBuilder {
1224    pub fn runtime_host_config(mut self, core: lash_core::RuntimeHostConfig) -> Self {
1225        self.builder.runtime_host_config = Some(core);
1226        self
1227    }
1228
1229    pub fn plugin_host(mut self, plugin_host: PluginHost) -> Self {
1230        self.builder.plugin_host = Some(plugin_host);
1231        self
1232    }
1233
1234    pub fn build(self) -> Result<LashCore> {
1235        self.builder.build()
1236    }
1237}