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