Skip to main content

lash_core/runtime/
environment.rs

1//! Shared process-level infrastructure for lash embedders.
2//!
3//! `RuntimeEnvironment` is the type an embedder constructs ONCE at
4//! startup and reuses across every `LashRuntime` instance it spawns.
5//! Fields are all `Arc`-wrapped or cheap-to-clone so building a runtime
6//! from an environment never rebuilds expensive state (plugin host,
7//! prompt layer, …).
8//!
9//! Three embedder patterns this enables:
10//!
11//! * **CLI interactive (single runtime, default):**
12//!   `RuntimeEnvironment::builder().build()`. The builder seeds an explicit
13//!   in-memory core (`RuntimeHostConfig::in_memory`); override it with
14//!   `with_runtime_host_config` for durable stores.
15//! * **Long autonomous agent:** set `residency` to `ActivePathOnly`,
16//!   then have the host periodically call
17//!   `runtime.orphaned_node_ids()` + `store.tombstone_nodes(...)` +
18//!   `store.vacuum()` on its own schedule. lash owns RAM; the host owns
19//!   disk lifecycle.
20//! * **Webserver multi-tenant:** one `RuntimeEnvironment` per process,
21//!   `residency: ActivePathOnly`, and `park()` / `resume()` per
22//!   request. HTTP connection pooling is a provider concern —
23//!   provider crates accept an optional shared HTTP client in
24//!   their constructors, so the host can share one pool across every
25//!   materialized provider.
26
27use std::sync::Arc;
28
29use lash_trace::{TraceContext, TraceLevel, TraceSink};
30
31#[cfg(test)]
32use super::InlineEffectHost;
33use super::process::ProcessRegistry;
34use super::{EffectHost, RuntimeHostConfig, TerminationPolicy};
35
36/// Where session nodes live at runtime.
37///
38/// lash owns RAM; the host owns disk lifecycle. Under `ActivePathOnly`
39/// the runtime trims orphans from memory on load, but disk-side
40/// retention (tombstoning + vacuum) is the host's decision — call
41/// `LashRuntime::orphaned_node_ids` when you want the current orphan
42/// set and feed it into `store.tombstone_nodes` / `store.vacuum` on
43/// whatever schedule fits your deployment.
44#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
45pub enum Residency {
46    /// Every node resident in RAM. Default. Best for interactive /
47    /// branching UX where the user may rewind.
48    #[default]
49    KeepAll,
50    /// Only nodes reachable from `leaf_node_id` are resident. Orphans
51    /// live on disk and are loaded on demand via
52    /// `LashRuntime::get_historic_node`. Best for webserver embedders
53    /// with many concurrent idle sessions, and for autonomous agents
54    /// (combined with host-scheduled `tombstone_nodes` + `vacuum`).
55    ActivePathOnly,
56}
57
58/// Shared runtime infrastructure an embedder builds once and reuses
59/// across every `LashRuntime` it constructs.
60///
61/// Cloning is cheap — every field is either `Arc`-wrapped or small.
62/// Default values build an embedded runtime without process lifecycle
63/// support. Hosts that want long-running tools, async handles, subagents,
64/// or process controls must provide a process registry explicitly.
65#[derive(Clone)]
66pub struct RuntimeEnvironment {
67    // Shared plugin infrastructure. Created once; every session's
68    // `PluginSession` is built from it via `PluginHost::build_session`.
69    pub plugin_host: Option<Arc<crate::PluginHost>>,
70
71    // RAM footprint policy for the session graph. Default `KeepAll`
72    // matches legacy behaviour. Webserver and autonomous-agent
73    // embedders set `ActivePathOnly`; disk lifecycle is then the
74    // host's responsibility via `orphaned_node_ids` + `tombstone_nodes`
75    // + `vacuum`.
76    pub residency: Residency,
77
78    // Host-owned process lifecycle and local execution support.
79    pub process_registry: Option<Arc<dyn ProcessRegistry>>,
80
81    // Host-owned trigger subscription and host-event occurrence routing.
82    pub host_event_store: Option<Arc<dyn crate::HostEventStore>>,
83
84    // Store factory used by managed child sessions created from runtimes
85    // built with this environment.
86    pub session_store_factory: Option<Arc<dyn crate::SessionStoreFactory>>,
87
88    // Wakes the host's `ProcessWorkRunner` so a successful process start is
89    // consumed promptly. Threaded onto every `RuntimeHost` built from this
90    // environment (see `LashRuntime::from_environment`); `None` when no work
91    // runner is wired, in which case poking is a no-op.
92    pub process_work_poke: Option<super::ProcessWorkPoke>,
93
94    // Wakes the host's `QueuedWorkRunner` so queued turn work, including
95    // process wakes, drains promptly through the host-selected queue runner.
96    pub queued_work_poke: Option<super::QueuedWorkPoke>,
97
98    pub core: RuntimeHostConfig,
99}
100
101impl RuntimeEnvironment {
102    pub fn builder() -> RuntimeEnvironmentBuilder {
103        RuntimeEnvironmentBuilder::default()
104    }
105}
106
107/// Lightweight handle returned by `LashRuntime::park`. Holds no graph
108/// nodes, no plugin session, no HTTP client — just enough to
109/// `LashRuntime::resume` later. Cheap to cache per-session on a
110/// webserver; bounded memory cost regardless of session history size.
111pub struct ParkedSession {
112    pub(crate) session_id: String,
113    pub(crate) store: Arc<dyn crate::store::RuntimePersistence>,
114    pub(crate) policy: crate::SessionPolicy,
115}
116
117impl ParkedSession {
118    pub fn session_id(&self) -> &str {
119        &self.session_id
120    }
121}
122
123/// Fluent builder for `RuntimeEnvironment`.
124pub struct RuntimeEnvironmentBuilder {
125    env: RuntimeEnvironment,
126}
127
128impl Default for RuntimeEnvironmentBuilder {
129    fn default() -> Self {
130        // `RuntimeHostConfig` has no `Default`; the builder starts from an
131        // explicitly named in-memory core so the choice is visible in source.
132        // The `lash` facade always overrides this via `with_runtime_host_config`
133        // and rejects builds that never named their stores.
134        Self {
135            env: RuntimeEnvironment {
136                plugin_host: None,
137                residency: Residency::default(),
138                process_registry: None,
139                host_event_store: Some(Arc::new(crate::InMemoryHostEventStore::default())),
140                session_store_factory: None,
141                process_work_poke: None,
142                queued_work_poke: None,
143                core: RuntimeHostConfig::in_memory(),
144            },
145        }
146    }
147}
148
149impl RuntimeEnvironmentBuilder {
150    pub fn with_plugin_host(mut self, host: Arc<crate::PluginHost>) -> Self {
151        self.env.plugin_host = Some(host);
152        self
153    }
154
155    pub fn with_residency(mut self, residency: Residency) -> Self {
156        self.env.residency = residency;
157        self
158    }
159
160    pub fn with_process_registry(mut self, process_registry: Arc<dyn ProcessRegistry>) -> Self {
161        self.env.process_registry = Some(process_registry);
162        if let Some(host) = self.env.plugin_host.take() {
163            let abilities = super::builder::lashlang_abilities_for_process_registry(
164                host.lashlang_abilities(),
165                true,
166            );
167            self.env.plugin_host = Some(Arc::new(
168                host.as_ref().clone().with_lashlang_abilities(abilities),
169            ));
170        }
171        self
172    }
173
174    pub fn with_host_event_store(mut self, store: Arc<dyn crate::HostEventStore>) -> Self {
175        self.env.host_event_store = Some(store);
176        self
177    }
178
179    pub fn with_session_store_factory(
180        mut self,
181        factory: Arc<dyn crate::SessionStoreFactory>,
182    ) -> Self {
183        self.env.session_store_factory = Some(factory);
184        self
185    }
186
187    /// Set the poke handle that wakes the host's `ProcessWorkRunner`. Every
188    /// `RuntimeHost` built from this environment carries the poke, so the
189    /// process control seam can make consumption prompt after a start.
190    pub fn with_process_work_poke(mut self, poke: super::ProcessWorkPoke) -> Self {
191        self.env.process_work_poke = Some(poke);
192        self
193    }
194
195    pub fn with_queued_work_poke(mut self, poke: super::QueuedWorkPoke) -> Self {
196        self.env.queued_work_poke = Some(poke);
197        self
198    }
199
200    pub fn with_runtime_host_config(mut self, core: RuntimeHostConfig) -> Self {
201        self.env.core = core;
202        self
203    }
204
205    pub fn with_attachment_store(mut self, store: Arc<dyn crate::AttachmentStore>) -> Self {
206        self.env.core.durability.attachment_store = store;
207        self
208    }
209
210    pub fn with_prompt_template(mut self, template: crate::PromptTemplate) -> Self {
211        self.env.core.prompt.prompt.template = Some(template);
212        self
213    }
214
215    pub fn with_prompt_contribution(mut self, contribution: crate::PromptContribution) -> Self {
216        self.env.core.prompt.prompt.add_contribution(contribution);
217        self
218    }
219
220    pub fn with_replaced_prompt_slot(
221        mut self,
222        slot: crate::PromptSlot,
223        contributions: impl IntoIterator<Item = crate::PromptContribution>,
224    ) -> Self {
225        self.env
226            .core
227            .prompt
228            .prompt
229            .replace_slot(slot, contributions);
230        self
231    }
232
233    pub fn with_cleared_prompt_slot(mut self, slot: crate::PromptSlot) -> Self {
234        self.env.core.prompt.prompt.clear_slot(slot);
235        self
236    }
237
238    pub fn with_prompt_layer(mut self, prompt: crate::PromptLayer) -> Self {
239        self.env.core.prompt.prompt = prompt;
240        self
241    }
242
243    pub fn with_trace_sink(mut self, sink: Option<Arc<dyn TraceSink>>) -> Self {
244        self.env.core.tracing.trace_sink = sink;
245        self
246    }
247
248    pub fn with_lashlang_execution_sink(mut self, sink: Option<Arc<dyn TraceSink>>) -> Self {
249        self.env.core.tracing.lashlang_execution_sink = sink;
250        self
251    }
252
253    pub fn with_lashlang_execution_jsonl_path(mut self, path: Option<std::path::PathBuf>) -> Self {
254        self.env.core.tracing.lashlang_execution_sink =
255            path.map(|path| Arc::new(lash_trace::JsonlTraceSink::new(path)) as Arc<dyn TraceSink>);
256        self
257    }
258
259    pub fn with_trace_level(mut self, level: TraceLevel) -> Self {
260        self.env.core.tracing.trace_level = level;
261        self
262    }
263
264    pub fn with_trace_context(mut self, context: TraceContext) -> Self {
265        self.env.core.tracing.trace_context = context;
266        self
267    }
268
269    pub fn with_termination(mut self, termination: TerminationPolicy) -> Self {
270        self.env.core.control.termination = termination;
271        self
272    }
273
274    pub fn with_effect_host(mut self, effect_host: Arc<dyn EffectHost>) -> Self {
275        self.env.core.control.effect_host = effect_host;
276        self
277    }
278
279    pub fn with_provider_resolver(
280        mut self,
281        provider_resolver: Arc<dyn crate::RuntimeProviderResolver>,
282    ) -> Self {
283        self.env.core.providers.provider_resolver = provider_resolver;
284        self
285    }
286
287    pub fn build(self) -> RuntimeEnvironment {
288        self.env
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn builder_methods_configure_runtime_host() {
298        let attachment_store: Arc<dyn crate::AttachmentStore> =
299            Arc::new(crate::InMemoryAttachmentStore::new());
300        let effect_host: Arc<dyn EffectHost> = Arc::new(InlineEffectHost::default());
301        let trace_context = TraceContext::default().for_session("session-1");
302        let termination = TerminationPolicy {
303            treat_missing_done_as_failure: false,
304        };
305
306        let env = RuntimeEnvironment::builder()
307            .with_attachment_store(Arc::clone(&attachment_store))
308            .with_prompt_template(crate::default_prompt_template())
309            .with_trace_sink(Some(Arc::new(lash_trace::JsonlTraceSink::new(
310                std::env::temp_dir().join("lash-runtime-environment-builder-test.jsonl"),
311            ))))
312            .with_trace_level(TraceLevel::Extended)
313            .with_trace_context(trace_context.clone())
314            .with_termination(termination.clone())
315            .with_effect_host(Arc::clone(&effect_host))
316            .build();
317
318        assert!(Arc::ptr_eq(
319            &env.core.durability.attachment_store,
320            &attachment_store
321        ));
322        assert!(env.core.prompt.prompt.template.is_some());
323        assert!(env.core.tracing.trace_sink.is_some());
324        assert_eq!(env.core.tracing.trace_level, TraceLevel::Extended);
325        assert_eq!(env.core.tracing.trace_context, trace_context);
326        assert_eq!(
327            env.core.control.termination.treat_missing_done_as_failure,
328            termination.treat_missing_done_as_failure
329        );
330        assert!(Arc::ptr_eq(&env.core.control.effect_host, &effect_host));
331    }
332
333    #[test]
334    fn runtime_host_config_replaces_core_config() {
335        let mut core = RuntimeHostConfig::in_memory();
336        core.tracing.trace_level = TraceLevel::Extended;
337        core.control.termination = TerminationPolicy {
338            treat_missing_done_as_failure: false,
339        };
340
341        let env = RuntimeEnvironment::builder()
342            .with_trace_level(TraceLevel::Standard)
343            .with_runtime_host_config(core)
344            .build();
345
346        assert_eq!(env.core.tracing.trace_level, TraceLevel::Extended);
347        assert!(!env.core.control.termination.treat_missing_done_as_failure);
348    }
349
350    #[test]
351    fn runtime_environment_does_not_mirror_runtime_host_config_fields() {
352        let source = std::fs::read_to_string(
353            std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/runtime/environment.rs"),
354        )
355        .expect("read environment source");
356        for field in [
357            ["pub ", "attachment_store:"].concat(),
358            ["pub ", "prompt:"].concat(),
359            ["pub ", "trace_sink:"].concat(),
360            ["pub ", "trace_level:"].concat(),
361            ["pub ", "trace_context:"].concat(),
362            ["pub ", "termination:"].concat(),
363            ["pub ", "effect_host:"].concat(),
364            ["mirror ", "`RuntimeHostConfig`"].concat(),
365        ] {
366            assert!(
367                !source.contains(&field),
368                "found mirrored field/comment: {field}"
369            );
370        }
371    }
372}