Skip to main content

lash/
admin.rs

1pub(crate) use crate::session::SessionConfigPatch;
2use crate::support::*;
3pub use lash_core::{AcceptedInjectedTurnInput, PluginCommand, PluginQuery, PluginTask};
4
5#[derive(Clone)]
6pub struct Completions {
7    pub(crate) core: LashCore,
8}
9
10impl Completions {
11    pub async fn resolve(
12        &self,
13        key: lash_core::AwaitEventKey,
14        resolution: lash_core::Resolution,
15    ) -> Result<lash_core::ResolveOutcome> {
16        self.core
17            .env
18            .core
19            .control
20            .effect_host
21            .resolve_await_event(&key, resolution)
22            .await
23            .map_err(|err| EmbedError::Plugin(lash_core::PluginError::Session(err.to_string())))
24    }
25}
26
27#[derive(Clone)]
28pub struct CoreTriggerAdmin {
29    pub(crate) core: LashCore,
30}
31
32impl CoreTriggerAdmin {
33    pub async fn emit(
34        &self,
35        request: lash_core::TriggerOccurrenceRequest,
36        scoped_effect_controller: ScopedEffectController<'_>,
37    ) -> Result<lash_core::TriggerEmitReport> {
38        let store = self.core.env.trigger_store.as_ref().ok_or_else(|| {
39            EmbedError::Plugin(lash_core::PluginError::Session(
40                "trigger store is unavailable in this runtime".to_string(),
41            ))
42        })?;
43        let drivers = self.core.work_driver.drivers().await;
44        let router = lash_core::TriggerRouter::new(
45            Arc::clone(store),
46            self.core.env.process_registry.clone(),
47            drivers.process,
48        );
49        router
50            .emit(request, scoped_effect_controller.controller())
51            .await
52            .map_err(Into::into)
53    }
54
55    pub async fn subscriptions(
56        &self,
57        filter: lash_core::TriggerSubscriptionFilter,
58    ) -> Result<Vec<lash_core::TriggerRegistration>> {
59        let store = self.core.env.trigger_store.as_ref().ok_or_else(|| {
60            EmbedError::Plugin(lash_core::PluginError::Session(
61                "trigger store is unavailable in this runtime".to_string(),
62            ))
63        })?;
64        let records = store.list_subscriptions(filter).await?;
65        Ok(records
66            .iter()
67            .map(lash_core::TriggerRegistration::from)
68            .collect())
69    }
70}
71
72#[derive(Clone)]
73pub struct SessionAdmin {
74    pub(crate) runtime: RuntimeHandle,
75}
76
77impl SessionAdmin {
78    pub fn config(&self) -> SessionConfigAdmin {
79        SessionConfigAdmin {
80            control: self.clone(),
81        }
82    }
83
84    pub fn tools(&self) -> ToolAdmin {
85        ToolAdmin {
86            control: self.clone(),
87        }
88    }
89
90    pub fn commands(&self) -> SessionCommandAdmin {
91        SessionCommandAdmin {
92            control: self.clone(),
93        }
94    }
95
96    pub fn triggers(&self) -> SessionTriggerAdmin {
97        SessionTriggerAdmin {
98            control: self.clone(),
99        }
100    }
101
102    pub fn state(&self) -> SessionStateAdmin {
103        SessionStateAdmin {
104            control: self.clone(),
105        }
106    }
107
108    pub fn children(&self) -> ChildSessionAdmin {
109        ChildSessionAdmin {
110            control: self.clone(),
111        }
112    }
113
114    pub fn injection(&self) -> InjectionAdmin {
115        InjectionAdmin {
116            control: self.clone(),
117        }
118    }
119
120    pub fn protocol(&self) -> ProtocolAdmin {
121        ProtocolAdmin {
122            control: self.clone(),
123        }
124    }
125
126    pub fn processes(&self) -> SessionProcessAdmin {
127        SessionProcessAdmin {
128            control: self.clone(),
129        }
130    }
131
132    /// Run `f` against the locked runtime writer, then publish the resulting
133    /// observation. The body is the canonical `lock → call → publish_from`
134    /// stamp shared by nearly every mutating control method; publish happens
135    /// unconditionally once the closure returns.
136    async fn with_writer<F, T>(&self, f: F) -> T
137    where
138        F: AsyncFnOnce(&mut LashRuntime) -> T,
139    {
140        let writer = self.runtime.writer();
141        let mut runtime = writer.lock().await;
142        let value = f(&mut runtime).await;
143        self.runtime.publish_from(&runtime);
144        value
145    }
146
147    async fn update_config(&self, patch: SessionConfigPatch) -> Result<()> {
148        self.with_writer(async |runtime: &mut LashRuntime| {
149            runtime
150                .update_session_config(patch.provider, patch.model, patch.prompt)
151                .await;
152        })
153        .await;
154        Ok(())
155    }
156
157    async fn export_state(&self) -> lash_core::SessionSnapshot {
158        self.runtime.observe().read_view.to_snapshot()
159    }
160
161    async fn append_messages(&self, messages: Vec<PluginMessage>) -> Result<()> {
162        self.with_writer(async |runtime: &mut LashRuntime| {
163            runtime
164                .append_session_nodes(lash_core::AppendSessionNodesRequest {
165                    nodes: messages
166                        .into_iter()
167                        .map(lash_core::SessionAppendNode::message)
168                        .collect(),
169                    requires_ancestor_node_id: None,
170                })
171                .await
172                .map(|_| ())
173                .map_err(Into::into)
174        })
175        .await
176    }
177
178    async fn append_plugin_body(
179        &self,
180        plugin_type: impl Into<String>,
181        body: serde_json::Value,
182    ) -> Result<()> {
183        self.with_writer(async |runtime: &mut LashRuntime| {
184            runtime
185                .append_session_nodes(lash_core::AppendSessionNodesRequest {
186                    nodes: vec![lash_core::SessionAppendNode::plugin(plugin_type, body)],
187                    requires_ancestor_node_id: None,
188                })
189                .await
190                .map(|_| ())
191                .map_err(Into::into)
192        })
193        .await
194    }
195
196    async fn set_persisted_state(&self, state: RuntimeSessionState) -> Result<()> {
197        self.with_writer(async |runtime: &mut LashRuntime| {
198            runtime.set_persisted_state(state).map_err(Into::into)
199        })
200        .await
201    }
202
203    async fn set_prompt_template(&self, template: PromptTemplate) -> Result<()> {
204        self.with_writer(async |runtime: &mut LashRuntime| {
205            runtime.set_prompt_template(template).await;
206        })
207        .await;
208        Ok(())
209    }
210
211    async fn clear_prompt_template(&self) -> Result<()> {
212        self.with_writer(async |runtime: &mut LashRuntime| {
213            runtime.clear_prompt_template().await;
214        })
215        .await;
216        Ok(())
217    }
218
219    async fn add_prompt_contribution(&self, contribution: PromptContribution) -> Result<()> {
220        self.with_writer(async |runtime: &mut LashRuntime| {
221            runtime.add_prompt_contribution(contribution).await;
222        })
223        .await;
224        Ok(())
225    }
226
227    async fn replace_prompt_slot(
228        &self,
229        slot: PromptSlot,
230        contributions: impl IntoIterator<Item = PromptContribution>,
231    ) -> Result<()> {
232        self.with_writer(async |runtime: &mut LashRuntime| {
233            runtime.replace_prompt_slot(slot, contributions).await;
234        })
235        .await;
236        Ok(())
237    }
238
239    async fn clear_prompt_slot(&self, slot: PromptSlot) -> Result<()> {
240        self.with_writer(async |runtime: &mut LashRuntime| {
241            runtime.clear_prompt_slot(slot).await;
242        })
243        .await;
244        Ok(())
245    }
246
247    async fn apply_protocol_session_extension(
248        &self,
249        extension: lash_core::ProtocolSessionExtensionHandle,
250    ) -> Result<()> {
251        self.with_writer(async |runtime: &mut LashRuntime| {
252            runtime
253                .apply_protocol_session_extension(extension)
254                .await
255                .map_err(Into::into)
256        })
257        .await
258    }
259
260    async fn branch_to_node(
261        &self,
262        target_leaf: Option<String>,
263    ) -> Result<lash_core::SessionSnapshot> {
264        self.with_writer(async |runtime: &mut LashRuntime| {
265            runtime
266                .branch_to_node(target_leaf)
267                .await
268                .map_err(Into::into)
269        })
270        .await
271    }
272
273    /// Refresh the session graph from any background process that signalled it
274    /// changed. This is the honest name for what the core `await_background_work`
275    /// call does — a session-graph resync, **not** a terminal wait on background
276    /// work (that lives on the process admin's `await_output`). Renamed off the
277    /// old `SessionProcessAdmin::await_all` misnomer per the ADR 0019 grill.
278    pub(crate) async fn refresh_background_graph(&self) -> Result<()> {
279        self.with_writer(async |runtime: &mut LashRuntime| {
280            runtime.await_background_work().await.map_err(Into::into)
281        })
282        .await
283    }
284
285    fn process_registry(&self) -> Result<Arc<dyn lash_core::ProcessRegistry>> {
286        self.runtime
287            .observe()
288            .process_registry
289            .clone()
290            .ok_or_else(|| {
291                EmbedError::Plugin(lash_core::PluginError::Session(
292                    "process registry is unavailable in this runtime".to_string(),
293                ))
294            })
295    }
296
297    fn process_observer(&self) -> Result<lash_core::ProcessWorkObserver> {
298        Ok(lash_core::ProcessWorkObserver::new(
299            self.process_registry()?,
300        ))
301    }
302
303    /// An observer over the session's process registry, or `None` when this
304    /// runtime has no registry. Session-scoped reads use this so a registry-less
305    /// runtime observes an empty process set rather than erroring, matching the
306    /// pre-unification `list_process_handles` behavior.
307    fn process_observer_opt(&self) -> Option<lash_core::ProcessWorkObserver> {
308        self.runtime
309            .observe()
310            .process_registry
311            .clone()
312            .map(lash_core::ProcessWorkObserver::new)
313    }
314
315    /// The session scopes this session may address grants under: its root scope
316    /// plus its current agent frame, deduplicated. Mirrors the scope set the
317    /// runtime observation lists handles across, so the grant-scoped session
318    /// view sees exactly what the session can address.
319    fn process_visible_scopes(&self) -> Vec<lash_core::SessionScope> {
320        let observation = self.runtime.observe();
321        let root = observation.process_scope();
322        let mut scopes = vec![root.clone()];
323        let frame_id = observation.persisted_state.current_agent_frame_id.as_str();
324        if !frame_id.is_empty() {
325            let frame_scope =
326                lash_core::SessionScope::for_agent_frame(observation.session_id(), frame_id);
327            if frame_scope.id() != root.id() {
328                scopes.push(frame_scope);
329            }
330        }
331        scopes
332    }
333
334    async fn signal_process(
335        &self,
336        process_id: &str,
337        signal_name: String,
338        signal_id: String,
339        payload: serde_json::Value,
340        scoped_effect_controller: ScopedEffectController<'_>,
341    ) -> Result<lash_core::ProcessEvent> {
342        let writer = self.runtime.writer();
343        let runtime = writer.lock().await;
344        let session_id = runtime.session_id().to_string();
345        let processes = runtime.process_service()?;
346        let scope = lash_core::ProcessOpScope::new(scoped_effect_controller);
347        processes
348            .signal(
349                &session_id,
350                process_id,
351                signal_name,
352                signal_id,
353                payload,
354                scope,
355            )
356            .await
357            .map_err(EmbedError::Plugin)
358    }
359
360    async fn transfer_process_handles(
361        &self,
362        to_session_id: &str,
363        process_ids: Vec<String>,
364        scoped_effect_controller: ScopedEffectController<'_>,
365    ) -> Result<()> {
366        let writer = self.runtime.writer();
367        let runtime = writer.lock().await;
368        let session_id = runtime.session_id().to_string();
369        let processes = runtime.process_service()?;
370        let scope = lash_core::ProcessOpScope::new(scoped_effect_controller);
371        processes
372            .transfer(&session_id, to_session_id, process_ids, scope)
373            .await
374            .map_err(EmbedError::Plugin)
375    }
376
377    async fn await_process_output(
378        &self,
379        process_id: &str,
380    ) -> Result<lash_core::ProcessAwaitOutput> {
381        lash_core::ProcessAwaiter::polling(self.process_registry()?)
382            .await_terminal(process_id)
383            .await
384            .map_err(Into::into)
385    }
386
387    async fn request_process_abandon(
388        &self,
389        process_id: &str,
390        reason: Option<String>,
391    ) -> Result<lash_core::ObservedProcess> {
392        let session_id = self.runtime.observe().session_id().to_string();
393        let request = lash_core::AbandonRequest {
394            requested_by: format!("session:{session_id}"),
395            requested_at_ms: crate::process_admin::now_epoch_ms(),
396            reason,
397        };
398        self.process_registry()?
399            .request_process_abandon(process_id, request)
400            .await?;
401        self.process_observer()?
402            .process(process_id)
403            .await
404            .ok_or_else(|| {
405                EmbedError::Plugin(lash_core::PluginError::Session(format!(
406                    "process `{process_id}` vanished after recording its abandon request"
407                )))
408            })
409    }
410
411    async fn refresh_tool_catalog(&self) -> Result<()> {
412        self.with_writer(async |runtime: &mut LashRuntime| {
413            runtime
414                .refresh_session_tool_catalog()
415                .await
416                .map_err(Into::into)
417        })
418        .await
419    }
420
421    async fn submit_session_command(
422        &self,
423        command: lash_core::SessionCommand,
424        idempotency_key: impl Into<String>,
425    ) -> Result<lash_core::SessionCommandReceipt> {
426        let idempotency_key = idempotency_key.into();
427        self.with_writer(async |runtime: &mut LashRuntime| {
428            runtime
429                .submit_session_command(command, idempotency_key)
430                .await
431                .map_err(Into::into)
432        })
433        .await
434    }
435
436    async fn list_trigger_registrations(&self) -> Result<Vec<lash_core::TriggerRegistration>> {
437        self.with_writer(async |runtime: &mut LashRuntime| {
438            runtime
439                .list_trigger_registrations()
440                .await
441                .map_err(Into::into)
442        })
443        .await
444    }
445
446    async fn trigger_registrations_by_source_type(
447        &self,
448        source_type: impl Into<lash_core::TriggerEventType>,
449    ) -> Result<Vec<lash_core::TriggerRegistration>> {
450        self.with_writer(async |runtime: &mut LashRuntime| {
451            runtime
452                .trigger_registrations_by_source_type(source_type)
453                .await
454                .map_err(Into::into)
455        })
456        .await
457    }
458
459    async fn query_plugin_raw(
460        &self,
461        name: &str,
462        args: serde_json::Value,
463    ) -> Result<(String, serde_json::Value)> {
464        let observation = self.runtime.observe();
465        let session_id = observation.session_id().to_string();
466        observation
467            .query_plugin(name, args, Some(session_id))
468            .await
469            .map_err(Into::into)
470    }
471
472    async fn run_plugin_command_raw(
473        &self,
474        name: &str,
475        args: serde_json::Value,
476    ) -> Result<lash_core::PluginCommandReceipt<serde_json::Value>> {
477        let session_id = self.runtime.observe().session_id().to_string();
478        let writer = self.runtime.writer();
479        let mut runtime = writer.lock().await;
480        let receipt = runtime
481            .run_plugin_command(name, args, Some(session_id))
482            .await?;
483        self.record_plugin_operation_observations(&receipt.events, &receipt.pending_turn_inputs);
484        self.runtime.publish_from(&runtime);
485        Ok(receipt)
486    }
487
488    async fn run_plugin_task_raw_with_cancel(
489        &self,
490        name: &str,
491        args: serde_json::Value,
492        cancellation_token: CancellationToken,
493    ) -> Result<lash_core::PluginTaskReceipt<serde_json::Value>> {
494        let session_id = self.runtime.observe().session_id().to_string();
495        let writer = self.runtime.writer();
496        let mut runtime = writer.lock().await;
497        let scope_id = format!(
498            "{session_id}:plugin_task:{name}:{}",
499            lash_core::TurnActivityId::fresh().0
500        );
501        let scoped_effect_controller = runtime
502            .effect_host()
503            .scoped_static(lash_core::ExecutionScope::runtime_operation(scope_id))
504            .map_err(EmbedError::Runtime)?
505            .ok_or_else(|| {
506                EmbedError::Plugin(lash_core::PluginError::Session(
507                    "plugin task execution requires an effect host that can create a static runtime-operation scope".to_string(),
508                ))
509            })?;
510        let receipt = runtime
511            .run_plugin_task(
512                name,
513                args,
514                Some(session_id),
515                scoped_effect_controller,
516                cancellation_token,
517            )
518            .await?;
519        self.record_plugin_operation_observations(&receipt.events, &receipt.pending_turn_inputs);
520        self.runtime.publish_from(&runtime);
521        Ok(receipt)
522    }
523
524    fn record_plugin_operation_observations(
525        &self,
526        events: &[lash_core::PluginOwned<lash_core::PluginRuntimeEvent>],
527        pending_turn_inputs: &[lash_core::PendingTurnInput],
528    ) {
529        for owned in events {
530            self.runtime
531                .record_turn_activity(lash_core::TurnActivity::independent(
532                    lash_core::TurnEvent::PluginRuntime {
533                        plugin_id: owned.plugin_id.clone(),
534                        event: owned.value.clone(),
535                    },
536                ));
537        }
538        if !pending_turn_inputs.is_empty() {
539            self.runtime.record_queue_changed(
540                lash_core::SessionQueueEventKind::Enqueued,
541                pending_turn_inputs
542                    .iter()
543                    .map(|input| input.input_id.clone())
544                    .collect(),
545            );
546        }
547    }
548
549    async fn compact_context(
550        &self,
551        instructions: Option<String>,
552        scoped_effect_controller: ScopedEffectController<'_>,
553    ) -> Result<bool> {
554        self.with_writer(async |runtime: &mut LashRuntime| {
555            runtime
556                .compact_context(instructions, scoped_effect_controller)
557                .await
558                .map_err(Into::into)
559        })
560        .await
561    }
562
563    async fn persist_current_state(&self) -> Result<RuntimeSessionState> {
564        self.with_writer(async |runtime: &mut LashRuntime| {
565            runtime.await_background_work().await?;
566            Ok(runtime.export_persisted_state())
567        })
568        .await
569    }
570
571    async fn start_process(
572        &self,
573        request: lash_core::ProcessStartRequest,
574        scoped_effect_controller: ScopedEffectController<'_>,
575    ) -> Result<lash_core::ProcessHandleSummary> {
576        let writer = self.runtime.writer();
577        let runtime = writer.lock().await;
578        let session_id = runtime.session_id().to_string();
579        let processes = runtime.process_service()?;
580        let scope = lash_core::ProcessOpScope::new(scoped_effect_controller);
581        let summary = processes
582            .start_from_request(&session_id, request, scope)
583            .await
584            .map_err(EmbedError::Plugin)?;
585        self.runtime.record_process_changed(
586            SessionProcessEventKind::Started,
587            vec![summary.process_id.clone()],
588        );
589        Ok(summary)
590    }
591
592    async fn session_state_service(&self) -> Result<Arc<dyn SessionStateService>> {
593        self.runtime
594            .writer()
595            .lock()
596            .await
597            .session_state_service()
598            .map_err(Into::into)
599    }
600
601    async fn cancel_process(
602        &self,
603        process_id: &str,
604        scoped_effect_controller: ScopedEffectController<'_>,
605    ) -> Result<lash_core::ProcessCancelSummary> {
606        let writer = self.runtime.writer();
607        let runtime = writer.lock().await;
608        let session_id = runtime.session_id().to_string();
609        let processes = runtime.process_service()?;
610        let cancel_ability = runtime.process_cancel_ability();
611        let scope = lash_core::ProcessOpScope::new(scoped_effect_controller);
612        let summary = cancel_ability
613            .cancel_summary(
614                processes.as_ref(),
615                lash_core::ProcessCancelRequest::new(
616                    &session_id,
617                    process_id,
618                    scope,
619                    lash_core::ProcessCancelSource::HostApi,
620                )
621                .with_reason("requested by host API"),
622            )
623            .await
624            .map_err(EmbedError::Plugin)?;
625        self.runtime.record_process_changed(
626            SessionProcessEventKind::Cancelled,
627            vec![summary.process_id.clone()],
628        );
629        Ok(summary)
630    }
631
632    async fn cancel_visible_processes(
633        &self,
634        scoped_effect_controller: ScopedEffectController<'_>,
635    ) -> Result<Vec<lash_core::ProcessCancelSummary>> {
636        let writer = self.runtime.writer();
637        let runtime = writer.lock().await;
638        let session_id = runtime.session_id().to_string();
639        let processes = runtime.process_service()?;
640        let cancel_ability = runtime.process_cancel_ability();
641        let scope = lash_core::ProcessOpScope::new(scoped_effect_controller);
642        let summaries = cancel_ability
643            .cancel_all_visible(
644                processes.as_ref(),
645                lash_core::ProcessCancelAllRequest::new(
646                    &session_id,
647                    scope,
648                    lash_core::ProcessCancelSource::HostApi,
649                )
650                .with_reason("requested by host API"),
651            )
652            .await
653            .map_err(EmbedError::Plugin)?;
654        self.runtime.record_process_changed(
655            SessionProcessEventKind::Cancelled,
656            summaries
657                .iter()
658                .map(|summary| summary.process_id.clone())
659                .collect(),
660        );
661        Ok(summaries)
662    }
663
664    async fn snapshot_execution_state(&self) -> Result<Option<Vec<u8>>> {
665        self.with_writer(async |runtime: &mut LashRuntime| {
666            runtime.snapshot_execution_state().await.map_err(Into::into)
667        })
668        .await
669    }
670
671    async fn restore_execution_state(&self, bytes: &[u8]) -> Result<()> {
672        self.with_writer(async |runtime: &mut LashRuntime| {
673            runtime
674                .restore_execution_state(bytes)
675                .await
676                .map_err(Into::into)
677        })
678        .await
679    }
680
681    async fn tool_state(&self) -> Result<ToolState> {
682        self.runtime.observe().tool_state.clone().ok_or_else(|| {
683            EmbedError::Session(SessionError::Protocol(
684                "runtime session not available".to_string(),
685            ))
686        })
687    }
688
689    async fn apply_tool_state(&self, state: ToolState) -> Result<u64> {
690        self.with_writer(async |runtime: &mut LashRuntime| {
691            runtime
692                .apply_tool_state(state)
693                .await
694                .map_err(EmbedError::from)
695        })
696        .await
697    }
698
699    async fn restore_tool_state(&self, state: ToolState) -> Result<ToolRestoreReport> {
700        self.with_writer(async |runtime: &mut LashRuntime| {
701            runtime
702                .restore_tool_state(state)
703                .await
704                .map_err(EmbedError::from)
705        })
706        .await
707    }
708
709    async fn set_tool_membership(&self, tool_id: lash_core::ToolId, present: bool) -> Result<u64> {
710        self.set_tool_membership_many(&[(tool_id, present)]).await
711    }
712
713    async fn set_tool_membership_many(&self, updates: &[(lash_core::ToolId, bool)]) -> Result<u64> {
714        let mut state = self.tool_state().await?;
715        for (tool_id, present) in updates {
716            state
717                .set_membership(tool_id, *present)
718                .map_err(|err| EmbedError::Session(SessionError::Protocol(err.to_string())))?;
719        }
720        self.apply_tool_state(state).await
721    }
722
723    async fn active_tool_manifests(&self) -> Result<Vec<ToolManifest>> {
724        Ok(self.tool_state().await?.tool_manifests())
725    }
726
727    async fn add_tool_provider(&self, provider: Arc<dyn ToolProvider>) -> Result<ToolSourceHandle> {
728        let tool_registry = self.tool_registry().await?;
729        let handle = tool_registry
730            .add_tool_provider(provider)
731            .map_err(|err| EmbedError::Session(SessionError::Protocol(err.to_string())))?;
732        self.refresh_tool_catalog().await?;
733        Ok(handle)
734    }
735
736    async fn remove_tool_source(&self, handle: &ToolSourceHandle) -> Result<u64> {
737        let tool_registry = self.tool_registry().await?;
738        let generation = tool_registry
739            .remove_source(handle)
740            .map_err(|err| EmbedError::Session(SessionError::Protocol(err.to_string())))?;
741        self.refresh_tool_catalog().await?;
742        Ok(generation)
743    }
744
745    async fn create_child_session(&self, request: SessionCreateRequest) -> Result<SessionHandle> {
746        let writer = self.runtime.writer();
747        let runtime = writer.lock().await;
748        let lifecycle = runtime.session_lifecycle_service()?;
749        lifecycle.create_session(request).await.map_err(Into::into)
750    }
751
752    async fn close_child_session(&self, session_id: &str) -> Result<()> {
753        let writer = self.runtime.writer();
754        let runtime = writer.lock().await;
755        let lifecycle = runtime.session_lifecycle_service()?;
756        lifecycle
757            .close_session(session_id)
758            .await
759            .map_err(Into::into)
760    }
761
762    async fn activate_managed_session(&self, session_id: &str) -> Result<()> {
763        self.with_writer(async |runtime: &mut LashRuntime| {
764            runtime
765                .activate_managed_session(session_id)
766                .await
767                .map_err(Into::into)
768        })
769        .await
770    }
771
772    async fn inject_turn_input(
773        &self,
774        turn_id: &str,
775        id: Option<String>,
776        message: PluginMessage,
777    ) -> Result<()> {
778        self.inject_turn_inputs_for_turn(
779            turn_id,
780            vec![lash_core::InjectedTurnInput { id, message }],
781        )
782        .await
783    }
784
785    async fn inject_turn_inputs_for_turn(
786        &self,
787        turn_id: &str,
788        messages: Vec<lash_core::InjectedTurnInput>,
789    ) -> Result<()> {
790        for input in messages {
791            let source_key = input.id.map(|id| format!("injection:{id}"));
792            let turn_input = turn_input_from_plugin_message(input.message);
793            self.runtime
794                .enqueue_turn_input(
795                    turn_input,
796                    lash_core::TurnInputIngress::active_turn(
797                        turn_id,
798                        lash_core::TurnInputCheckpointBoundary::AfterWork,
799                    ),
800                    source_key,
801                )
802                .await
803                .map(|_| ())
804                .map_err(EmbedError::Runtime)?;
805        }
806        Ok(())
807    }
808
809    async fn tool_registry(&self) -> Result<Arc<lash_core::ToolRegistry>> {
810        self.runtime
811            .writer()
812            .lock()
813            .await
814            .plugin_session()
815            .map(|session| session.tool_registry())
816            .ok_or_else(|| {
817                EmbedError::Session(SessionError::Protocol(
818                    "tool registry is unavailable in this runtime session".to_string(),
819                ))
820            })
821    }
822}
823
824fn turn_input_from_plugin_message(message: PluginMessage) -> TurnInput {
825    let mut input = TurnInput::empty();
826    if !message.content.is_empty() {
827        input.items.push(InputItem::Text {
828            text: message.content,
829        });
830    }
831    for source in message.attachments {
832        input.items.push(InputItem::attachment(source));
833    }
834    input
835}
836
837#[derive(Clone)]
838pub struct SessionConfigAdmin {
839    control: SessionAdmin,
840}
841
842impl SessionConfigAdmin {
843    pub async fn update(&self, patch: SessionConfigPatch) -> Result<()> {
844        self.control.update_config(patch).await
845    }
846
847    pub async fn set_prompt_template(&self, template: PromptTemplate) -> Result<()> {
848        self.control.set_prompt_template(template).await
849    }
850
851    pub async fn clear_prompt_template(&self) -> Result<()> {
852        self.control.clear_prompt_template().await
853    }
854
855    pub async fn add_prompt_contribution(&self, contribution: PromptContribution) -> Result<()> {
856        self.control.add_prompt_contribution(contribution).await
857    }
858
859    pub async fn replace_prompt_slot(
860        &self,
861        slot: PromptSlot,
862        contributions: impl IntoIterator<Item = PromptContribution>,
863    ) -> Result<()> {
864        self.control.replace_prompt_slot(slot, contributions).await
865    }
866
867    pub async fn clear_prompt_slot(&self, slot: PromptSlot) -> Result<()> {
868        self.control.clear_prompt_slot(slot).await
869    }
870}
871
872#[derive(Clone)]
873pub struct ToolAdmin {
874    control: SessionAdmin,
875}
876
877impl ToolAdmin {
878    pub(crate) fn new(control: SessionAdmin) -> Self {
879        Self { control }
880    }
881}
882
883impl ToolAdmin {
884    pub async fn state(&self) -> Result<ToolState> {
885        self.control.tool_state().await
886    }
887
888    pub fn advanced(&self) -> AdvancedToolAdmin {
889        AdvancedToolAdmin {
890            control: self.control.clone(),
891        }
892    }
893
894    /// Toggle Tool Catalog membership for a tool. `present` adds it as a
895    /// member; `!present` removes it. Membership is the execution gate.
896    pub async fn set_membership(
897        &self,
898        tool_id: impl Into<lash_core::ToolId>,
899        present: bool,
900    ) -> Result<u64> {
901        self.control
902            .set_tool_membership(tool_id.into(), present)
903            .await
904    }
905
906    pub async fn set_membership_many(&self, updates: &[(lash_core::ToolId, bool)]) -> Result<u64> {
907        self.control.set_tool_membership_many(updates).await
908    }
909
910    pub async fn active_manifests(&self) -> Result<Vec<ToolManifest>> {
911        self.control.active_tool_manifests().await
912    }
913
914    pub async fn add_provider(&self, provider: Arc<dyn ToolProvider>) -> Result<ToolSourceHandle> {
915        self.control.add_tool_provider(provider).await
916    }
917
918    pub async fn remove_source(&self, handle: &ToolSourceHandle) -> Result<u64> {
919        self.control.remove_tool_source(handle).await
920    }
921}
922
923#[derive(Clone)]
924pub struct AdvancedToolAdmin {
925    control: SessionAdmin,
926}
927
928impl AdvancedToolAdmin {
929    /// Replace the entire tool-state snapshot.
930    ///
931    /// This is a generation-checked escape hatch for hosts that intentionally
932    /// edit the full snapshot. Prefer `ToolAdmin` membership methods for
933    /// ordinary tool policy changes.
934    pub async fn apply_state(&self, state: ToolState) -> Result<u64> {
935        self.control.apply_tool_state(state).await
936    }
937
938    /// Restore a persisted tool-state snapshot, adopting its generation.
939    ///
940    /// Use this when re-applying a snapshot read from durable storage (session
941    /// resume), not an edited delta: it reconstructs the exact persisted surface
942    /// idempotently rather than requiring the snapshot to match the current
943    /// generation. A cold resume of a session whose surface reached generation
944    /// ≥ 2 needs this — [`apply_state`](Self::apply_state) would reject it.
945    ///
946    /// Persisted tools whose source is not currently registered (e.g. a
947    /// detached MCP server) do not fail the restore: they are kept as orphaned
948    /// non-members, listed in the returned [`ToolRestoreReport`], and rebind
949    /// automatically when a source re-advertises the same tool.
950    pub async fn restore_state(&self, state: ToolState) -> Result<ToolRestoreReport> {
951        self.control.restore_tool_state(state).await
952    }
953}
954
955#[derive(Clone)]
956pub struct SessionCommandAdmin {
957    control: SessionAdmin,
958}
959
960impl SessionCommandAdmin {
961    /// Enqueue an unconditional tool-catalog refresh. The command drains
962    /// asynchronously and recomputes the surface from live sources, so it
963    /// takes no generation guard — any generation observed at enqueue time
964    /// could legitimately have advanced by drain time.
965    pub async fn refresh_tool_catalog(
966        &self,
967        reason: impl Into<String>,
968        idempotency_key: impl Into<String>,
969    ) -> Result<lash_core::SessionCommandReceipt> {
970        self.control
971            .submit_session_command(
972                lash_core::SessionCommand::RefreshToolCatalog {
973                    reason: reason.into(),
974                },
975                idempotency_key,
976            )
977            .await
978    }
979
980    pub async fn reset(
981        &self,
982        reason: impl Into<String>,
983        idempotency_key: impl Into<String>,
984    ) -> Result<lash_core::SessionCommandReceipt> {
985        self.control
986            .submit_session_command(
987                lash_core::SessionCommand::ResetSession {
988                    reason: reason.into(),
989                },
990                idempotency_key,
991            )
992            .await
993    }
994}
995
996/// Session-scoped read controls for Lashlang trigger registrations.
997#[derive(Clone)]
998pub struct SessionTriggerAdmin {
999    control: SessionAdmin,
1000}
1001
1002impl SessionTriggerAdmin {
1003    /// Return every trigger registration in the session.
1004    ///
1005    /// This is an admin/introspection view. Source owners should prefer
1006    /// [`Self::by_source_type`] so they only inspect registrations for the
1007    /// concrete source type they own.
1008    pub async fn list_all(&self) -> Result<Vec<lash_core::TriggerRegistration>> {
1009        self.control.list_trigger_registrations().await
1010    }
1011
1012    /// Return registrations whose source value has the given host descriptor type.
1013    ///
1014    /// This is the source-owner API: a timer, UI, webhook, or other host-owned
1015    /// source uses it to inspect registrations for keys it may schedule and emit.
1016    pub async fn by_source_type(
1017        &self,
1018        source_type: impl Into<lash_core::TriggerEventType>,
1019    ) -> Result<Vec<lash_core::TriggerRegistration>> {
1020        self.control
1021            .trigger_registrations_by_source_type(source_type)
1022            .await
1023    }
1024}
1025
1026#[derive(Clone)]
1027pub struct SessionProcessAdmin {
1028    control: SessionAdmin,
1029}
1030
1031/// Session-scoped view of the global process surface ([`Processes`]).
1032///
1033/// This is thin sugar, not a parallel surface (ADR 0019 grill): every read is
1034/// the global observer pre-filtered by this session's **grant** scope (what the
1035/// session may address), and every mutation delegates to the same runtime
1036/// process path the global surface uses. It speaks the same [`ObservedProcess`]
1037/// vocabulary as [`Processes`]; [`start`](Self::start) returns the
1038/// grant-entry handle row ([`lash_core::ProcessHandleSummary`]), the one row
1039/// type retained for the model/handle contract.
1040impl SessionProcessAdmin {
1041    pub(crate) fn new(control: SessionAdmin) -> Self {
1042        Self { control }
1043    }
1044
1045    /// Grant-scoped read: the global observer filtered to every scope this
1046    /// session may address (root + current frame), deduplicated. One home for
1047    /// the session's read logic — it calls the observer, never reimplements it.
1048    async fn list_granted(
1049        &self,
1050        filter: &lash_core::ProcessListFilter,
1051    ) -> Result<Vec<lash_core::ObservedProcess>> {
1052        // A registry-less runtime has no processes to address; observe empty
1053        // rather than erroring, matching the pre-unification session read.
1054        let Some(observer) = self.control.process_observer_opt() else {
1055            return Ok(Vec::new());
1056        };
1057        let mut seen = std::collections::BTreeSet::new();
1058        let mut out = Vec::new();
1059        for scope in self.control.process_visible_scopes() {
1060            for process in observer.list_granted_to(&scope, filter).await? {
1061                if seen.insert(process.process_id.clone()) {
1062                    out.push(process);
1063                }
1064            }
1065        }
1066        Ok(out)
1067    }
1068
1069    pub async fn start(
1070        &self,
1071        request: lash_core::ProcessStartRequest,
1072        scoped_effect_controller: ScopedEffectController<'_>,
1073    ) -> Result<lash_core::ProcessHandleSummary> {
1074        self.control
1075            .start_process(request, scoped_effect_controller)
1076            .await
1077    }
1078
1079    /// Running processes this session may address.
1080    pub async fn list(&self) -> Result<Vec<lash_core::ObservedProcess>> {
1081        self.list_granted(&lash_core::ProcessListFilter {
1082            status: lash_core::ProcessStatusFilter::Running,
1083            ..lash_core::ProcessListFilter::default()
1084        })
1085        .await
1086    }
1087
1088    /// Every process (any status) this session may address.
1089    pub async fn list_all(&self) -> Result<Vec<lash_core::ObservedProcess>> {
1090        self.list_granted(&lash_core::ProcessListFilter {
1091            status: lash_core::ProcessStatusFilter::Any,
1092            ..lash_core::ProcessListFilter::default()
1093        })
1094        .await
1095    }
1096
1097    /// One process this session may address, if present.
1098    pub async fn get(&self, process_id: &str) -> Result<Option<lash_core::ObservedProcess>> {
1099        Ok(self
1100            .list_all()
1101            .await?
1102            .into_iter()
1103            .find(|process| process.process_id == process_id))
1104    }
1105
1106    pub async fn events(
1107        &self,
1108        process_id: &str,
1109        after_sequence: u64,
1110    ) -> Result<Vec<lash_core::ObservedProcessEvent>> {
1111        let Some(observer) = self.control.process_observer_opt() else {
1112            return Ok(Vec::new());
1113        };
1114        observer
1115            .events_after(process_id, after_sequence)
1116            .await
1117            .map_err(Into::into)
1118    }
1119
1120    pub async fn await_output(&self, process_id: &str) -> Result<lash_core::ProcessAwaitOutput> {
1121        self.control.await_process_output(process_id).await
1122    }
1123
1124    pub async fn signal(
1125        &self,
1126        process_id: &str,
1127        signal_name: impl Into<String>,
1128        signal_id: impl Into<String>,
1129        payload: serde_json::Value,
1130        scoped_effect_controller: ScopedEffectController<'_>,
1131    ) -> Result<lash_core::ProcessEvent> {
1132        self.control
1133            .signal_process(
1134                process_id,
1135                signal_name.into(),
1136                signal_id.into(),
1137                payload,
1138                scoped_effect_controller,
1139            )
1140            .await
1141    }
1142
1143    pub async fn cancel(
1144        &self,
1145        process_id: &str,
1146        scoped_effect_controller: ScopedEffectController<'_>,
1147    ) -> Result<lash_core::ProcessCancelSummary> {
1148        self.control
1149            .cancel_process(process_id, scoped_effect_controller)
1150            .await
1151    }
1152
1153    pub async fn cancel_all(
1154        &self,
1155        scoped_effect_controller: ScopedEffectController<'_>,
1156    ) -> Result<Vec<lash_core::ProcessCancelSummary>> {
1157        self.control
1158            .cancel_visible_processes(scoped_effect_controller)
1159            .await
1160    }
1161
1162    /// Move this session's handle grants for `process_ids` to another session.
1163    /// Re-homes the addressing grant only; the process itself is global.
1164    pub async fn transfer(
1165        &self,
1166        to_session_id: &str,
1167        process_ids: Vec<String>,
1168        scoped_effect_controller: ScopedEffectController<'_>,
1169    ) -> Result<()> {
1170        self.control
1171            .transfer_process_handles(to_session_id, process_ids, scoped_effect_controller)
1172            .await
1173    }
1174
1175    /// Record an Abandon Request (ADR 0019) against a process this session
1176    /// owns: an authorization the recovery sweep reconciles into `Abandoned`
1177    /// once the owner's lease lapses. Returns the process as observed after.
1178    pub async fn request_abandon(
1179        &self,
1180        process_id: &str,
1181        reason: Option<String>,
1182    ) -> Result<lash_core::ObservedProcess> {
1183        self.control
1184            .request_process_abandon(process_id, reason)
1185            .await
1186    }
1187}
1188
1189#[derive(Clone)]
1190pub struct SessionStateAdmin {
1191    control: SessionAdmin,
1192}
1193
1194impl SessionStateAdmin {
1195    pub async fn export(&self) -> lash_core::SessionSnapshot {
1196        self.control.export_state().await
1197    }
1198
1199    pub async fn append_messages(&self, messages: Vec<PluginMessage>) -> Result<()> {
1200        self.control.append_messages(messages).await
1201    }
1202
1203    pub async fn append_plugin_body(
1204        &self,
1205        plugin_type: impl Into<String>,
1206        body: serde_json::Value,
1207    ) -> Result<()> {
1208        self.control.append_plugin_body(plugin_type, body).await
1209    }
1210
1211    pub async fn set_persisted(&self, state: RuntimeSessionState) -> Result<()> {
1212        self.control.set_persisted_state(state).await
1213    }
1214
1215    pub async fn branch_to_node(
1216        &self,
1217        target_leaf: Option<String>,
1218    ) -> Result<lash_core::SessionSnapshot> {
1219        self.control.branch_to_node(target_leaf).await
1220    }
1221
1222    pub async fn persist_current(&self) -> Result<RuntimeSessionState> {
1223        self.control.persist_current_state().await
1224    }
1225
1226    pub async fn session_state_service(&self) -> Result<Arc<dyn SessionStateService>> {
1227        self.control.session_state_service().await
1228    }
1229
1230    pub async fn snapshot_execution(&self) -> Result<Option<Vec<u8>>> {
1231        self.control.snapshot_execution_state().await
1232    }
1233
1234    pub async fn restore_execution(&self, bytes: &[u8]) -> Result<()> {
1235        self.control.restore_execution_state(bytes).await
1236    }
1237
1238    pub async fn compact_context(
1239        &self,
1240        instructions: Option<String>,
1241        scoped_effect_controller: ScopedEffectController<'_>,
1242    ) -> Result<bool> {
1243        self.control
1244            .compact_context(instructions, scoped_effect_controller)
1245            .await
1246    }
1247}
1248
1249#[derive(Clone)]
1250pub struct PluginOperations {
1251    pub(crate) control: SessionAdmin,
1252}
1253
1254impl PluginOperations {
1255    pub async fn query<Op: lash_core::PluginQuery>(&self, args: Op::Args) -> Result<Op::Output> {
1256        let (_plugin_id, output) = self
1257            .control
1258            .query_plugin_raw(Op::NAME, encode_plugin_args::<Op>(args)?)
1259            .await?;
1260        decode_plugin_output::<Op>(output)
1261    }
1262
1263    pub async fn query_raw(
1264        &self,
1265        name: &str,
1266        args: serde_json::Value,
1267    ) -> Result<(String, serde_json::Value)> {
1268        self.control.query_plugin_raw(name, args).await
1269    }
1270
1271    pub async fn run_command<Op: lash_core::PluginCommand>(
1272        &self,
1273        args: Op::Args,
1274    ) -> Result<lash_core::PluginCommandReceipt<Op::Output>> {
1275        let receipt = self
1276            .control
1277            .run_plugin_command_raw(Op::NAME, encode_plugin_args::<Op>(args)?)
1278            .await?;
1279        Ok(lash_core::PluginCommandReceipt {
1280            output: decode_plugin_output::<Op>(receipt.output)?,
1281            events: receipt.events,
1282            pending_turn_inputs: receipt.pending_turn_inputs,
1283        })
1284    }
1285
1286    pub async fn run_command_raw(
1287        &self,
1288        name: &str,
1289        args: serde_json::Value,
1290    ) -> Result<lash_core::PluginCommandReceipt<serde_json::Value>> {
1291        self.control.run_plugin_command_raw(name, args).await
1292    }
1293
1294    pub async fn run_task<Op: lash_core::PluginTask>(
1295        &self,
1296        args: Op::Args,
1297    ) -> Result<lash_core::PluginTaskReceipt<Op::Output>> {
1298        self.run_task_with_cancel::<Op>(args, CancellationToken::new())
1299            .await
1300    }
1301
1302    pub async fn run_task_with_cancel<Op: lash_core::PluginTask>(
1303        &self,
1304        args: Op::Args,
1305        cancellation_token: CancellationToken,
1306    ) -> Result<lash_core::PluginTaskReceipt<Op::Output>> {
1307        let receipt = self
1308            .control
1309            .run_plugin_task_raw_with_cancel(
1310                Op::NAME,
1311                encode_plugin_args::<Op>(args)?,
1312                cancellation_token,
1313            )
1314            .await?;
1315        Ok(lash_core::PluginTaskReceipt {
1316            output: decode_plugin_output::<Op>(receipt.output)?,
1317            events: receipt.events,
1318            pending_turn_inputs: receipt.pending_turn_inputs,
1319        })
1320    }
1321
1322    pub async fn run_task_raw(
1323        &self,
1324        name: &str,
1325        args: serde_json::Value,
1326    ) -> Result<lash_core::PluginTaskReceipt<serde_json::Value>> {
1327        self.run_task_raw_with_cancel(name, args, CancellationToken::new())
1328            .await
1329    }
1330
1331    pub async fn run_task_raw_with_cancel(
1332        &self,
1333        name: &str,
1334        args: serde_json::Value,
1335        cancellation_token: CancellationToken,
1336    ) -> Result<lash_core::PluginTaskReceipt<serde_json::Value>> {
1337        self.control
1338            .run_plugin_task_raw_with_cancel(name, args, cancellation_token)
1339            .await
1340    }
1341}
1342
1343fn encode_plugin_args<Op: lash_core::PluginOperation>(args: Op::Args) -> Result<serde_json::Value> {
1344    serde_json::to_value(args).map_err(|err| {
1345        EmbedError::Plugin(lash_core::PluginError::Invoke(format!(
1346            "invalid {} args: {err}",
1347            Op::NAME
1348        )))
1349    })
1350}
1351
1352fn decode_plugin_output<Op: lash_core::PluginOperation>(
1353    output: serde_json::Value,
1354) -> Result<Op::Output> {
1355    serde_json::from_value(output).map_err(|err| {
1356        EmbedError::Plugin(lash_core::PluginError::Invoke(format!(
1357            "invalid {} output: {err}",
1358            Op::NAME
1359        )))
1360    })
1361}
1362
1363#[derive(Clone)]
1364pub struct ChildSessionAdmin {
1365    control: SessionAdmin,
1366}
1367
1368impl ChildSessionAdmin {
1369    pub async fn create_session(&self, request: SessionCreateRequest) -> Result<SessionHandle> {
1370        self.control.create_child_session(request).await
1371    }
1372
1373    pub async fn close_session(&self, session_id: &str) -> Result<()> {
1374        self.control.close_child_session(session_id).await
1375    }
1376
1377    pub async fn activate_managed_session(&self, session_id: &str) -> Result<()> {
1378        self.control.activate_managed_session(session_id).await
1379    }
1380}
1381
1382#[derive(Clone)]
1383pub struct InjectionAdmin {
1384    control: SessionAdmin,
1385}
1386
1387impl InjectionAdmin {
1388    pub async fn inject_turn_input(
1389        &self,
1390        turn_id: &str,
1391        id: Option<String>,
1392        message: PluginMessage,
1393    ) -> Result<()> {
1394        self.control.inject_turn_input(turn_id, id, message).await
1395    }
1396
1397    pub async fn inject_turn_inputs_for_turn(
1398        &self,
1399        turn_id: &str,
1400        messages: Vec<lash_core::InjectedTurnInput>,
1401    ) -> Result<()> {
1402        self.control
1403            .inject_turn_inputs_for_turn(turn_id, messages)
1404            .await
1405    }
1406}
1407
1408#[derive(Clone)]
1409pub struct ProtocolAdmin {
1410    control: SessionAdmin,
1411}
1412
1413impl ProtocolAdmin {
1414    pub async fn apply_session_extension(
1415        &self,
1416        extension: lash_core::ProtocolSessionExtensionHandle,
1417    ) -> Result<()> {
1418        self.control
1419            .apply_protocol_session_extension(extension)
1420            .await
1421    }
1422}