Skip to main content

lash_core/plugin/
registry.rs

1//! Plugin registration: `PluginSpec` (the declarative bundle of all a
2//! plugin's hooks), the `PluginFactory` / `SessionPlugin` traits
3//! plugin crates implement, and the two convenience factories
4//! (`StaticPluginFactory`, `PluginSpecFactory`) + the `SpecPlugin`
5//! glue that walks a spec and wires each field into the registrar.
6//!
7//! Split out of `plugin/mod.rs` for file size; outer path preserved by
8//! `pub use` in `plugin/mod.rs`.
9
10use std::sync::Arc;
11
12use super::{
13    AfterToolCallHook, AfterTurnHook, AssistantResponseHook, AssistantStreamHook,
14    BeforeToolCallHook, BeforeTurnHook, CheckpointHook, ContextCompactor, PluginAction,
15    PluginActionDef, PluginActionHandler, PluginError, PluginHost, PluginLifecycleEventHook,
16    PluginRegistrar, PluginSnapshotMeta, PromptContributor, SessionConfigMutator,
17    SessionToolAccess, SnapshotReader, SnapshotWriter, SubagentSessionContext,
18    ToolDiscoveryContributor, ToolResultProjector, ToolSurfaceContributor, TurnContextTransform,
19};
20use crate::ToolProvider;
21
22#[derive(Clone, Default)]
23pub struct PluginSpec {
24    pub tool_providers: Vec<Arc<dyn ToolProvider>>,
25    pub host_events: Vec<crate::HostEvent>,
26    pub prompt_contributors: Vec<PromptContributor>,
27    pub tool_surface_contributors: Vec<ToolSurfaceContributor>,
28    pub tool_discovery_contributors: Vec<ToolDiscoveryContributor>,
29    pub before_turn_hooks: Vec<BeforeTurnHook>,
30    pub before_tool_call_hooks: Vec<BeforeToolCallHook>,
31    pub after_tool_call_hooks: Vec<AfterToolCallHook>,
32    pub after_turn_hooks: Vec<AfterTurnHook>,
33    pub checkpoint_hooks: Vec<CheckpointHook>,
34    pub assistant_stream_hooks: Vec<AssistantStreamHook>,
35    pub assistant_response_hooks: Vec<AssistantResponseHook>,
36    pub tool_result_projector: Option<ToolResultProjector>,
37    pub runtime_event_hooks: Vec<PluginLifecycleEventHook>,
38    pub session_config_mutators: Vec<SessionConfigMutator>,
39    pub plugin_actions: Vec<(PluginActionDef, PluginActionHandler)>,
40    pub turn_context_transforms: Vec<(i32, Arc<dyn TurnContextTransform>)>,
41    pub context_compactors: Vec<(i32, Arc<dyn ContextCompactor>)>,
42}
43
44impl PluginSpec {
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    pub fn with_tool_provider(mut self, provider: Arc<dyn ToolProvider>) -> Self {
50        self.tool_providers.push(provider);
51        self
52    }
53
54    pub fn with_host_event(mut self, event: crate::HostEvent) -> Self {
55        self.host_events.push(event);
56        self
57    }
58
59    pub fn with_prompt_contributor(mut self, contributor: PromptContributor) -> Self {
60        self.prompt_contributors.push(contributor);
61        self
62    }
63
64    pub fn with_tool_surface_contributor(mut self, contributor: ToolSurfaceContributor) -> Self {
65        self.tool_surface_contributors.push(contributor);
66        self
67    }
68
69    pub fn with_tool_discovery_contributor(
70        mut self,
71        contributor: ToolDiscoveryContributor,
72    ) -> Self {
73        self.tool_discovery_contributors.push(contributor);
74        self
75    }
76
77    pub fn with_before_turn(mut self, hook: BeforeTurnHook) -> Self {
78        self.before_turn_hooks.push(hook);
79        self
80    }
81
82    pub fn with_before_tool_call(mut self, hook: BeforeToolCallHook) -> Self {
83        self.before_tool_call_hooks.push(hook);
84        self
85    }
86
87    pub fn with_after_tool_call(mut self, hook: AfterToolCallHook) -> Self {
88        self.after_tool_call_hooks.push(hook);
89        self
90    }
91
92    pub fn with_after_turn(mut self, hook: AfterTurnHook) -> Self {
93        self.after_turn_hooks.push(hook);
94        self
95    }
96
97    pub fn with_checkpoint(mut self, hook: CheckpointHook) -> Self {
98        self.checkpoint_hooks.push(hook);
99        self
100    }
101
102    pub fn with_assistant_stream(mut self, hook: AssistantStreamHook) -> Self {
103        self.assistant_stream_hooks.push(hook);
104        self
105    }
106
107    pub fn with_assistant_response(mut self, hook: AssistantResponseHook) -> Self {
108        self.assistant_response_hooks.push(hook);
109        self
110    }
111
112    pub fn with_tool_result_projector(mut self, projector: ToolResultProjector) -> Self {
113        self.tool_result_projector = Some(projector);
114        self
115    }
116
117    pub fn with_runtime_event(mut self, hook: PluginLifecycleEventHook) -> Self {
118        self.runtime_event_hooks.push(hook);
119        self
120    }
121
122    pub fn with_session_config_mutator(mut self, hook: SessionConfigMutator) -> Self {
123        self.session_config_mutators.push(hook);
124        self
125    }
126
127    pub fn with_plugin_action(
128        mut self,
129        def: PluginActionDef,
130        handler: PluginActionHandler,
131    ) -> Self {
132        self.plugin_actions.push((def, handler));
133        self
134    }
135
136    pub fn with_plugin_action_typed<Op, F, Fut>(self, handler: F) -> Self
137    where
138        Op: PluginAction,
139        F: Fn(super::PluginActionContext, Op::Args) -> Fut + Send + Sync + 'static,
140        Fut: std::future::Future<Output = Result<Op::Output, super::PluginActionFailure>>
141            + Send
142            + 'static,
143    {
144        self.with_plugin_action(
145            super::plugin_action_def::<Op>(),
146            Arc::new(move |ctx, args| {
147                let parsed = serde_json::from_value::<Op::Args>(args);
148                match parsed {
149                    Ok(args) => {
150                        let fut = handler(ctx, args);
151                        Box::pin(async move {
152                            match fut.await {
153                                Ok(output) => match serde_json::to_value(output) {
154                                    Ok(value) => crate::ToolResult::ok(value),
155                                    Err(err) => crate::ToolResult::err(serde_json::json!(format!(
156                                        "failed to serialize {} output: {err}",
157                                        Op::NAME
158                                    ))),
159                                },
160                                Err(err) => {
161                                    crate::ToolResult::err(serde_json::json!(err.to_string()))
162                                }
163                            }
164                        })
165                    }
166                    Err(err) => Box::pin(async move {
167                        crate::ToolResult::err(serde_json::json!(format!(
168                            "invalid {} args: {err}",
169                            Op::NAME
170                        )))
171                    }),
172                }
173            }),
174        )
175    }
176
177    pub fn with_plugin_action_sync<Op, F>(self, handler: F) -> Self
178    where
179        Op: PluginAction,
180        F: Fn(
181                super::PluginActionContext,
182                Op::Args,
183            ) -> Result<Op::Output, super::PluginActionFailure>
184            + Send
185            + Sync
186            + 'static,
187    {
188        self.with_plugin_action_typed::<Op, _, _>(move |ctx, args| {
189            let result = handler(ctx, args);
190            async move { result }
191        })
192    }
193
194    pub fn with_turn_context_transform(
195        mut self,
196        priority: i32,
197        transform: Arc<dyn TurnContextTransform>,
198    ) -> Self {
199        self.turn_context_transforms.push((priority, transform));
200        self
201    }
202
203    pub fn with_context_compactor(
204        mut self,
205        priority: i32,
206        compactor: Arc<dyn ContextCompactor>,
207    ) -> Self {
208        self.context_compactors.push((priority, compactor));
209        self
210    }
211}
212
213#[derive(Clone, Debug)]
214pub struct PluginSessionContext {
215    pub session_id: String,
216    pub tool_access: SessionToolAccess,
217    pub subagent: Option<SubagentSessionContext>,
218    pub lashlang_abilities: lashlang::LashlangAbilities,
219    pub lashlang_language_features: lashlang::LashlangLanguageFeatures,
220    /// Session id of the caller that created this one. `None` identifies
221    /// a root session; any subagent / compaction / forked-child session
222    /// carries the parent here so plugin factories can gate themselves
223    /// on root-only behavior (e.g. `update_plan`'s sticky plan dock).
224    pub parent_session_id: Option<String>,
225}
226
227impl PluginSessionContext {
228    /// Returns `true` when this context represents a root session, not a
229    /// subagent or internal child. Plugins that should only surface in
230    /// user-facing top-level turns check this in their `build`.
231    pub fn is_root_session(&self) -> bool {
232        self.parent_session_id.is_none()
233    }
234}
235
236#[derive(Clone)]
237pub struct SessionReadyContext {
238    pub session_id: String,
239    pub host: PluginHost,
240}
241
242pub trait SessionPlugin: Send + Sync {
243    fn id(&self) -> &'static str;
244
245    fn version(&self) -> &'static str {
246        "1"
247    }
248
249    fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError>;
250
251    fn snapshot(
252        &self,
253        _writer: &mut dyn SnapshotWriter,
254    ) -> Result<PluginSnapshotMeta, PluginError> {
255        Ok(PluginSnapshotMeta {
256            plugin_id: self.id().to_string(),
257            plugin_version: self.version().to_string(),
258            revision: self.snapshot_revision(),
259            state: None,
260        })
261    }
262
263    fn snapshot_revision(&self) -> u64 {
264        0
265    }
266
267    fn restore(
268        &self,
269        _meta: &PluginSnapshotMeta,
270        _reader: &dyn SnapshotReader,
271    ) -> Result<(), PluginError> {
272        Ok(())
273    }
274
275    fn session_ready(&self, _ctx: SessionReadyContext) -> Result<(), PluginError> {
276        Ok(())
277    }
278}
279
280/// Registers a plugin with the runtime and produces a per-session
281/// `SessionPlugin` instance for each new session.
282///
283/// # Cheap-build / stateful-factory contract
284///
285/// `build(ctx)` **must be cheap**. It runs on the hot path every time
286/// a new session is created (including subagents, forked children,
287/// and compaction children) and any latency here is paid per session.
288///
289/// Specifically, `build` must **not**:
290/// - perform any I/O (disk reads, HTTP calls, DB queries),
291/// - compile regexes, templates, or schemas,
292/// - open network connections or initialize connection pools,
293/// - load models, parse large config files, or allocate large buffers,
294/// - block the current thread for non-trivial work.
295///
296/// Expensive state belongs on the `PluginFactory` struct itself,
297/// wrapped in `Arc` so it can be cheaply cloned into per-session
298/// closures. The `PluginFactory` is constructed once by the embedder
299/// and held in the `RuntimeEnvironment`; its fields outlive every
300/// session. Hooks captured into a `PluginSpec` are closures that
301/// clone the `Arc`s off `self` and reference the shared state
302/// directly, so every session sees the same pool / cache / compiled
303/// artifact without rebuilding it.
304///
305/// The typical shape is:
306/// ```ignore
307/// pub struct MyFactory {
308///     pool: Arc<ConnectionPool>,          // expensive, built once
309///     compiled: Arc<Regex>,               // expensive, built once
310/// }
311///
312/// impl PluginFactory for MyFactory {
313///     fn id(&self) -> &'static str { "my_plugin" }
314///
315///     fn build(&self, _ctx: &PluginSessionContext)
316///         -> Result<Arc<dyn SessionPlugin>, PluginError>
317///     {
318///         // Cheap: clone Arcs, assemble spec, wrap in SpecPlugin.
319///         let pool = Arc::clone(&self.pool);
320///         let spec = PluginSpec::new().with_before_turn(Arc::new(move |_ctx| {
321///             let pool = Arc::clone(&pool);
322///             Box::pin(async move { /* use pool */ Ok(vec![]) })
323///         }));
324///         Ok(Arc::new(SpecPluginFromSpec::new("my_plugin", spec)))
325///     }
326/// }
327/// ```
328pub trait PluginFactory: Send + Sync {
329    fn id(&self) -> &'static str;
330
331    fn lashlang_abilities(&self) -> lashlang::LashlangAbilities {
332        lashlang::LashlangAbilities::default()
333    }
334
335    fn lashlang_language_features(&self) -> lashlang::LashlangLanguageFeatures {
336        lashlang::LashlangLanguageFeatures::default()
337    }
338
339    /// Host-owned Lashlang catalog entries that code may link against.
340    ///
341    /// This only affects the execution surface. It is intentionally not
342    /// rendered into prompts automatically; hosts remain responsible for
343    /// describing their resources through prompt contributions.
344    fn lashlang_resources(&self) -> lashlang::ResourceCatalog {
345        lashlang::ResourceCatalog::new()
346    }
347
348    /// Produce a session-scoped plugin. **Must be cheap** — see the
349    /// trait-level docs for the full contract.
350    fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError>;
351}
352
353pub type PluginSpecBuilder =
354    Arc<dyn Fn(&PluginSessionContext) -> Result<PluginSpec, PluginError> + Send + Sync>;
355
356pub struct PluginSpecFactory {
357    id: &'static str,
358    builder: PluginSpecBuilder,
359}
360
361impl PluginSpecFactory {
362    pub fn new(id: &'static str, builder: PluginSpecBuilder) -> Self {
363        Self { id, builder }
364    }
365}
366
367pub struct StaticPluginFactory {
368    id: &'static str,
369    spec: PluginSpec,
370}
371
372impl StaticPluginFactory {
373    pub fn new(id: &'static str, spec: PluginSpec) -> Self {
374        Self { id, spec }
375    }
376}
377
378struct SpecPlugin {
379    id: &'static str,
380    spec: PluginSpec,
381}
382
383impl PluginFactory for PluginSpecFactory {
384    fn id(&self) -> &'static str {
385        self.id
386    }
387
388    fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
389        Ok(Arc::new(SpecPlugin {
390            id: self.id,
391            spec: (self.builder)(ctx)?,
392        }))
393    }
394}
395
396impl PluginFactory for StaticPluginFactory {
397    fn id(&self) -> &'static str {
398        self.id
399    }
400
401    fn build(&self, _ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
402        Ok(Arc::new(SpecPlugin {
403            id: self.id,
404            spec: self.spec.clone(),
405        }))
406    }
407}
408
409impl SessionPlugin for SpecPlugin {
410    fn id(&self) -> &'static str {
411        self.id
412    }
413
414    fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
415        for provider in &self.spec.tool_providers {
416            reg.tools().provider(Arc::clone(provider))?;
417        }
418        for event in &self.spec.host_events {
419            reg.host_events().declare(event.clone())?;
420        }
421        for contributor in &self.spec.prompt_contributors {
422            reg.prompt().contribute(Arc::clone(contributor));
423        }
424        for contributor in &self.spec.tool_surface_contributors {
425            reg.surface().contribute(Arc::clone(contributor));
426        }
427        for contributor in &self.spec.tool_discovery_contributors {
428            reg.discovery().contribute(Arc::clone(contributor));
429        }
430        for hook in &self.spec.before_turn_hooks {
431            reg.turn().before(Arc::clone(hook));
432        }
433        for hook in &self.spec.before_tool_call_hooks {
434            reg.tool_calls().before(Arc::clone(hook));
435        }
436        for hook in &self.spec.after_tool_call_hooks {
437            reg.tool_calls().after(Arc::clone(hook));
438        }
439        for hook in &self.spec.after_turn_hooks {
440            reg.turn().after(Arc::clone(hook));
441        }
442        for hook in &self.spec.checkpoint_hooks {
443            reg.turn().checkpoint(Arc::clone(hook));
444        }
445        for hook in &self.spec.assistant_stream_hooks {
446            reg.output().stream(Arc::clone(hook));
447        }
448        for hook in &self.spec.assistant_response_hooks {
449            reg.output().response(Arc::clone(hook));
450        }
451        if let Some(projector) = &self.spec.tool_result_projector {
452            reg.tool_results().projector(Arc::clone(projector))?;
453        }
454        for hook in &self.spec.runtime_event_hooks {
455            reg.session().on_event(Arc::clone(hook));
456        }
457        for hook in &self.spec.session_config_mutators {
458            reg.session().config_mutator(Arc::clone(hook));
459        }
460        for (def, handler) in &self.spec.plugin_actions {
461            reg.actions().op(def.clone(), Arc::clone(handler))?;
462        }
463        for (priority, transform) in &self.spec.turn_context_transforms {
464            reg.context().prepare_turn(*priority, Arc::clone(transform));
465        }
466        for (priority, compactor) in &self.spec.context_compactors {
467            reg.context().compact(*priority, Arc::clone(compactor));
468        }
469        Ok(())
470    }
471}