Skip to main content

lash_core/plugin/
mod.rs

1use std::future::Future;
2use std::sync::Arc;
3
4use crate::monitor::{MonitorSnapshot, MonitorSpec, MonitorUpdateBatch};
5use crate::runtime::{AssembledTurn, PersistedSessionState};
6use crate::{
7    ExecutionMode, MessageRole, ModeTurnOptions, SessionPolicy, ToolAvailability, ToolDefinition,
8    ToolManifest, ToolProvider, ToolResult, TurnInput,
9};
10
11pub use lash_sansio::{
12    CheckpointKind, PluginMessage, PluginSurfaceEvent, PromptContribution, ToolSurfaceContribution,
13    ToolSurfaceOverride,
14};
15
16mod actions;
17mod error;
18mod history;
19mod hooks;
20mod mode;
21mod monitor;
22mod registrar;
23mod registry;
24pub mod runtime_host;
25mod runtime_impl;
26mod services;
27mod session_obj;
28mod session_types;
29mod snapshot;
30mod surface;
31mod tool_result_projection_builtin;
32
33pub(crate) use actions::RegisteredPluginAction;
34pub use actions::{
35    PluginAction, PluginActionContext, PluginActionDef, PluginActionFailure, PluginActionFuture,
36    PluginActionHandler, PluginActionInvokeFuture, PluginActionKind, SessionParam,
37    plugin_action_def,
38};
39pub use error::PluginError;
40pub use history::{
41    HistoryError, HistoryRewriteMetadata, HistoryRewriter, HistoryState, RewriteContext,
42    RewriteTrigger, SessionReadView, TurnContextTransform, TurnTransformContext,
43};
44pub use hooks::{
45    AfterToolCallHook, AfterTurnHook, AssistantResponseHook, AssistantResponseHookContext,
46    AssistantResponseTransform, AssistantStreamHook, AssistantStreamHookContext,
47    AssistantStreamTransform, BeforeToolCallHook, BeforeTurnHook, CheckpointHook,
48    CheckpointHookContext, PluginFuture, PluginRuntimeEvent, PluginRuntimeEventHook,
49    PluginSessionTask, PromptContributor, PromptHookContext, SessionConfigChangedContext,
50    SessionConfigMutator, SessionStateChangedContext, ToolCallHookContext,
51    ToolDiscoveryContributor, ToolResultHookContext, ToolResultProjectionContext,
52    ToolResultProjector, ToolSurfaceContributor, TurnHookContext, TurnResultHookContext,
53    TurnResultSummary,
54};
55pub use mode::{
56    ModeBeforeLlmCallContext, ModeExtras, ModeLlmCallAction, ModeNativeToolsPlugin,
57    ModeProtocolDriverPlugin, ModeRuntimeContext, ModeSessionContext, ModeSessionPlugin,
58    StandardCreateExtras,
59};
60pub use monitor::{
61    AckWakeArgs, MonitorAckWakeOp, MonitorEmptyArgs, MonitorRegisterSpecsOp, MonitorStartOp,
62    MonitorStatusOp, MonitorStopOp, MonitorTakeUpdatesOp, OwnedMonitorSpec, RegisterSpecsArgs,
63    StartMonitorArgs, StopMonitorArgs,
64};
65pub use registrar::{
66    HistoryRegistrations, ModeRegistrations, MonitorRegistrations, OutputRegistrations,
67    PluginActionRegistrations, PluginRegistrar, PromptRegistrations, SessionRegistrations,
68    SurfaceRegistrations, ToolCallRegistrations, ToolRegistrations, ToolResultRegistrations,
69    TurnRegistrations,
70};
71pub(crate) use registrar::{RegisteredExclusiveHook, RegisteredHook};
72pub use registry::{
73    PluginFactory, PluginSessionContext, PluginSpec, PluginSpecBuilder, PluginSpecFactory,
74    SessionPlugin, SessionReadyContext, StaticPluginFactory,
75};
76pub use runtime_host::{
77    AppendSessionNodesRequest, AppendSessionNodesResult, DirectCompletion, DirectLlmCompletion,
78};
79pub(crate) use runtime_host::{
80    CheckpointHookHost, DirectCompletionHost, HistoryHost, MonitorHost, PluginActionHost,
81    PromptHookHost, RuntimeSessionHost, SessionGraphHost, SessionLifecycleHost,
82    SessionSnapshotHost, TaskHost, ToolCatalogHost, ToolHookHost, ToolStateHost, TraceHost,
83    TurnHookHost, TurnHost, TurnResultHookHost,
84};
85pub use runtime_impl::{PluginHost, SessionAuthorityContext};
86pub(crate) use services::NoopSessionManager;
87pub use services::{PersistentRuntimeServices, PluginActionInvokeError, RuntimeServices};
88pub use session_obj::PluginSession;
89pub use session_types::{
90    PluginOwned, SessionAppendNode, SessionContextSurface, SessionCreateRequest, SessionHandle,
91    SessionPluginMode, SessionRelation, SessionSnapshot, SessionStartPoint, SessionToolAccess,
92    SessionTurnHandle, SubagentSessionAuthority,
93};
94pub(crate) use snapshot::{InMemorySnapshotReader, InMemorySnapshotWriter};
95pub use snapshot::{
96    PluginSessionSnapshot, PluginSnapshotArtifact, PluginSnapshotEntry, PluginSnapshotMeta,
97    SnapshotReader, SnapshotWriter,
98};
99pub use surface::{
100    CheckpointApplication, PluginAbort, PluginDirective, PrepareTurnRequest, ToolDiscoveryContext,
101    ToolDiscoveryContribution, ToolDiscoveryToolContribution, ToolSurfaceContext, TurnFinalization,
102    TurnPreparation,
103};
104pub(crate) use surface::{emit_plugin_surface_events, plugin_surface_session_events};
105pub use tool_result_projection_builtin::{
106    DEFAULT_TOOL_OUTPUT_BUDGET_LIMIT_BYTES, DEFAULT_TOOL_OUTPUT_BUDGET_MAX_LINES,
107    ToolOutputBudgetConfig, ToolOutputBudgetMode, ToolOutputBudgetPluginFactory,
108    observation_projection_metadata, project_observation_text, truncate_observation_text,
109};
110
111pub(crate) fn builtin_plugin_factories() -> Vec<Arc<dyn PluginFactory>> {
112    // Mode plugins (`lash-mode-standard`, `lash-mode-rlm`) must be
113    // registered by the embedder before calling `PluginHost::build_session`.
114    // lash's own test suite uses an in-tree fake (`testing::test_mode_factories()`)
115    // to avoid a dev-dep cycle through the mode crates.
116    let factories: Vec<Arc<dyn PluginFactory>> = vec![Arc::new(monitor::MonitorPluginFactory)];
117    #[cfg(not(test))]
118    return factories;
119
120    #[cfg(test)]
121    {
122        factories
123            .into_iter()
124            .chain(crate::testing::test_mode_factories())
125            .collect()
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use schemars::JsonSchema;
132    use serde::{Deserialize, Serialize};
133    use serde_json::json;
134
135    use super::*;
136    use crate::{ExecutionMode, SessionStateEnvelope, ToolDefinition};
137
138    struct MockToolProvider;
139
140    #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
141    struct TypedEchoArgs {
142        value: String,
143    }
144
145    #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
146    struct TypedEchoOutput {
147        value: String,
148        session_id: Option<String>,
149    }
150
151    struct TypedEchoOp;
152
153    impl PluginAction for TypedEchoOp {
154        const NAME: &'static str = "mock.typed_echo";
155        const DESCRIPTION: &'static str = "typed echo";
156        const KIND: PluginActionKind = PluginActionKind::Query;
157        const SESSION_PARAM: SessionParam = SessionParam::Optional;
158        type Args = TypedEchoArgs;
159        type Output = TypedEchoOutput;
160    }
161
162    #[async_trait::async_trait]
163    impl ToolProvider for MockToolProvider {
164        fn tool_manifests(&self) -> Vec<ToolManifest> {
165            self.tool_definitions()
166                .into_iter()
167                .map(|tool| tool.manifest())
168                .collect()
169        }
170
171        fn resolve_contract(&self, name: &str) -> Option<Arc<crate::ToolContract>> {
172            self.tool_definitions()
173                .into_iter()
174                .find(|tool| tool.name == name)
175                .map(|tool| Arc::new(tool.contract()))
176        }
177
178        async fn execute(&self, call: crate::ToolCall<'_>) -> ToolResult {
179            ToolResult::ok(call.args.clone())
180        }
181    }
182
183    impl MockToolProvider {
184        fn tool_definitions(&self) -> Vec<ToolDefinition> {
185            vec![
186                ToolDefinition::raw(
187                    "mock_tool",
188                    "",
189                    json!({
190                        "type": "object",
191                        "properties": { "value": { "type": "string" } },
192                        "required": ["value"],
193                        "additionalProperties": false
194                    }),
195                    json!({ "type": "string" }),
196                )
197                .with_availability(crate::ToolAvailabilityConfig::callable()),
198            ]
199        }
200    }
201
202    struct MockPluginFactory;
203
204    impl PluginFactory for MockPluginFactory {
205        fn id(&self) -> &'static str {
206            "mock"
207        }
208
209        fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
210            Ok(Arc::new(MockPlugin {
211                session_id: ctx.session_id.clone(),
212            }))
213        }
214    }
215
216    struct MockPlugin {
217        session_id: String,
218    }
219
220    use crate::testing::MockSessionManager;
221
222    impl SessionPlugin for MockPlugin {
223        fn id(&self) -> &'static str {
224            "mock"
225        }
226
227        fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
228            reg.tools().provider(Arc::new(MockToolProvider))?;
229            reg.prompt().contribute(Arc::new(|_ctx| {
230                Box::pin(async move {
231                    Ok(vec![
232                        PromptContribution::guidance("Plugin Prompt", "Structured plugin prompt"),
233                        PromptContribution::guidance("Dynamic Note", "dynamic note")
234                            .with_priority(1),
235                    ])
236                })
237            }));
238            let session_id = self.session_id.clone();
239            reg.actions().op(
240                PluginActionDef {
241                    name: "mock.echo".to_string(),
242                    description: "echo".to_string(),
243                    kind: PluginActionKind::Query,
244                    session_param: SessionParam::Optional,
245                    input_schema: json!({}),
246                    output_schema: json!({}),
247                },
248                Arc::new(move |ctx, args| {
249                    let session_id = session_id.clone();
250                    Box::pin(async move {
251                        ToolResult::ok(json!({
252                            "session_id": ctx.session_id,
253                            "plugin_session_id": session_id,
254                            "args": args,
255                        }))
256                    })
257                }),
258            )?;
259            reg.actions()
260                .typed::<TypedEchoOp, _, _>(move |ctx, args| async move {
261                    Ok(TypedEchoOutput {
262                        value: args.value,
263                        session_id: ctx.session_id,
264                    })
265                })?;
266            Ok(())
267        }
268
269        fn snapshot(
270            &self,
271            _writer: &mut dyn SnapshotWriter,
272        ) -> Result<PluginSnapshotMeta, PluginError> {
273            Ok(PluginSnapshotMeta {
274                plugin_id: self.id().to_string(),
275                plugin_version: self.version().to_string(),
276                revision: self.snapshot_revision(),
277                state: Some(json!({"session_id": self.session_id})),
278            })
279        }
280    }
281
282    #[tokio::test]
283    async fn session_collects_tools_and_prompts() {
284        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
285        let session = host.build_standard_session("root", None).expect("session");
286        assert_eq!(session.tools().tool_manifests().len(), 1);
287        let contributions = session
288            .collect_prompt_contributions(PromptHookContext {
289                session_id: "root".to_string(),
290                host: Arc::new(MockSessionManager::default()),
291                state: SessionReadView::from_exported_state(&SessionStateEnvelope::default()),
292                mode_turn_options: ModeTurnOptions::default(),
293                turn_context: crate::TurnContext::default(),
294            })
295            .await
296            .expect("prompt contributions");
297        assert_eq!(
298            contributions,
299            vec![
300                PromptContribution::guidance("Plugin Prompt", "Structured plugin prompt"),
301                PromptContribution::guidance("Dynamic Note", "dynamic note").with_priority(1),
302            ]
303        );
304    }
305
306    #[tokio::test]
307    async fn external_invoke_defaults_to_current_session_when_requested() {
308        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
309        let session = host.build_standard_session("root", None).expect("session");
310        let result = session
311            .invoke_plugin_action(
312                "mock.echo",
313                json!({"ok":true}),
314                None,
315                true,
316                Arc::new(MockSessionManager::default()),
317            )
318            .await
319            .expect("invoke");
320        assert!(result.is_success());
321        assert_eq!(
322            result
323                .value_for_projection()
324                .get("session_id")
325                .and_then(|v| v.as_str()),
326            Some("root")
327        );
328    }
329
330    #[tokio::test]
331    async fn plugin_action_generates_schema_and_invokes_typed_output() {
332        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
333        let session = host.build_standard_session("root", None).expect("session");
334
335        let def = session
336            .plugin_actions()
337            .into_iter()
338            .find(|def| def.name == TypedEchoOp::NAME)
339            .expect("typed op definition");
340        assert_eq!(def.kind, PluginActionKind::Query);
341        assert_eq!(def.session_param, SessionParam::Optional);
342        let value_type = def
343            .input_schema
344            .pointer("/schema/properties/value/type")
345            .or_else(|| def.input_schema.pointer("/properties/value/type"))
346            .and_then(serde_json::Value::as_str);
347        assert_eq!(value_type, Some("string"));
348
349        let output = session
350            .call_plugin_action::<TypedEchoOp>(
351                TypedEchoArgs {
352                    value: "hello".to_string(),
353                },
354                None,
355                true,
356                Arc::new(MockSessionManager::default()),
357            )
358            .await
359            .expect("typed invoke");
360        assert_eq!(output.value, "hello");
361        assert_eq!(output.session_id.as_deref(), Some("root"));
362    }
363
364    #[test]
365    fn plugin_action_rejects_duplicate_names() {
366        struct DuplicatePlugin;
367
368        impl SessionPlugin for DuplicatePlugin {
369            fn id(&self) -> &'static str {
370                "duplicate"
371            }
372
373            fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
374                reg.actions()
375                    .typed::<TypedEchoOp, _, _>(move |ctx, args| async move {
376                        Ok(TypedEchoOutput {
377                            value: args.value,
378                            session_id: ctx.session_id,
379                        })
380                    })?;
381                reg.actions()
382                    .typed::<TypedEchoOp, _, _>(move |ctx, args| async move {
383                        Ok(TypedEchoOutput {
384                            value: args.value,
385                            session_id: ctx.session_id,
386                        })
387                    })
388            }
389        }
390
391        struct DuplicateFactory;
392        impl PluginFactory for DuplicateFactory {
393            fn id(&self) -> &'static str {
394                "duplicate"
395            }
396
397            fn build(
398                &self,
399                _ctx: &PluginSessionContext,
400            ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
401                Ok(Arc::new(DuplicatePlugin))
402            }
403        }
404
405        let err = match PluginHost::new(vec![Arc::new(DuplicateFactory)])
406            .build_standard_session("root", None)
407        {
408            Ok(_) => panic!("duplicate typed plugin action should fail"),
409            Err(err) => err,
410        };
411        assert!(err.to_string().contains("duplicate plugin action name"));
412    }
413
414    #[tokio::test]
415    async fn typed_external_invoke_errors_on_failed_or_invalid_output() {
416        struct BadOp;
417        impl PluginAction for BadOp {
418            const NAME: &'static str = "mock.echo";
419            const DESCRIPTION: &'static str = "bad typed projection over raw op";
420            const KIND: PluginActionKind = PluginActionKind::Query;
421            const SESSION_PARAM: SessionParam = SessionParam::Optional;
422            type Args = TypedEchoArgs;
423            type Output = TypedEchoOutput;
424        }
425
426        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
427        let session = host.build_standard_session("root", None).expect("session");
428        let err = session
429            .call_plugin_action::<BadOp>(
430                TypedEchoArgs {
431                    value: "hello".to_string(),
432                },
433                None,
434                true,
435                Arc::new(MockSessionManager::default()),
436            )
437            .await
438            .expect_err("raw output shape should not match typed output");
439        assert!(err.to_string().contains("invalid mock.echo output"));
440    }
441
442    #[tokio::test]
443    async fn plugin_host_can_invoke_plugin_action_for_registered_session() {
444        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
445        let _session = host.build_standard_session("root", None).expect("session");
446
447        let result = host
448            .invoke_plugin_action_for_session(
449                "root",
450                "mock.echo",
451                json!({"ok":true}),
452                Arc::new(MockSessionManager::default()),
453            )
454            .await
455            .expect("invoke");
456        assert!(result.is_success());
457        assert_eq!(
458            result
459                .value_for_projection()
460                .get("session_id")
461                .and_then(|v| v.as_str()),
462            Some("root")
463        );
464        assert_eq!(
465            result
466                .value_for_projection()
467                .get("plugin_session_id")
468                .and_then(|v| v.as_str()),
469            Some("root")
470        );
471    }
472
473    #[tokio::test]
474    async fn plugin_host_can_invoke_plugin_action_for_forked_session() {
475        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
476        let root = host.build_standard_session("root", None).expect("root");
477        let child = root
478            .fork_for_session(
479                "child",
480                ExecutionMode::standard(),
481                Some(crate::StandardContextApproach::default()),
482            )
483            .expect("child");
484
485        let result = host
486            .invoke_plugin_action_for_session(
487                "child",
488                "mock.echo",
489                json!({"ok":true}),
490                Arc::new(MockSessionManager::default()),
491            )
492            .await
493            .expect("invoke");
494        assert!(result.is_success());
495        assert_eq!(
496            result
497                .value_for_projection()
498                .get("session_id")
499                .and_then(|v| v.as_str()),
500            Some("child")
501        );
502        assert_eq!(
503            result
504                .value_for_projection()
505                .get("plugin_session_id")
506                .and_then(|v| v.as_str()),
507            Some("child")
508        );
509
510        drop(child);
511    }
512
513    #[test]
514    fn plugin_host_unregisters_sessions() {
515        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
516        let _session = host.build_standard_session("root", None).expect("session");
517        assert!(host.session("root").is_ok());
518        host.unregister_session("root").expect("unregister");
519        match host.session("root") {
520            Err(PluginActionInvokeError::UnknownSession(id)) => assert_eq!(id, "root"),
521            Ok(_) => panic!("expected missing session"),
522            Err(other) => panic!("unexpected error: {other}"),
523        }
524    }
525
526    #[test]
527    fn snapshot_round_trip_preserves_plugin_entries() {
528        let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
529        let session = host.build_standard_session("root", None).expect("session");
530        let snapshot = session.snapshot().expect("snapshot");
531        assert!(snapshot.plugins.contains_key("mock"));
532        let restored = host
533            .build_standard_session("child", Some(&snapshot))
534            .expect("restored");
535        let restored_snapshot = restored.snapshot().expect("snapshot");
536        assert!(restored_snapshot.plugins.contains_key("mock"));
537    }
538
539    #[test]
540    fn runtime_services_are_backed_by_plugin_sessions() {
541        let host = PluginHost::new(vec![Arc::new(StaticPluginFactory::new(
542            "mock_tool",
543            PluginSpec::new()
544                .with_tool_provider(Arc::new(MockToolProvider) as Arc<dyn ToolProvider>),
545        ))]);
546        let services =
547            RuntimeServices::new(host.build_standard_session("root", None).expect("session"));
548        assert_eq!(services.plugins.session_id(), "root");
549        assert!(
550            services
551                .plugins
552                .tools()
553                .tool_manifests()
554                .iter()
555                .any(|tool| tool.name == "mock_tool")
556        );
557    }
558
559    struct ProjectorPluginFactory {
560        plugin_id: &'static str,
561    }
562
563    impl PluginFactory for ProjectorPluginFactory {
564        fn id(&self) -> &'static str {
565            self.plugin_id
566        }
567
568        fn build(
569            &self,
570            _ctx: &PluginSessionContext,
571        ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
572            Ok(Arc::new(ProjectorPlugin {
573                plugin_id: self.plugin_id,
574            }))
575        }
576    }
577
578    struct ProjectorPlugin {
579        plugin_id: &'static str,
580    }
581
582    impl SessionPlugin for ProjectorPlugin {
583        fn id(&self) -> &'static str {
584            self.plugin_id
585        }
586
587        fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
588            reg.tool_results().projector(Arc::new(|ctx| {
589                Box::pin(async move {
590                    Ok(crate::ModelToolReturn::from_output(
591                        ctx.call_id,
592                        ctx.tool_name,
593                        &ctx.output,
594                    ))
595                })
596            }))
597        }
598    }
599
600    #[test]
601    fn duplicate_tool_result_projectors_are_rejected() {
602        let host = PluginHost::new(vec![
603            Arc::new(ProjectorPluginFactory {
604                plugin_id: "projector-a",
605            }),
606            Arc::new(ProjectorPluginFactory {
607                plugin_id: "projector-b",
608            }),
609        ]);
610        let err = match host.build_standard_session("root", None) {
611            Ok(_) => panic!("duplicate projector"),
612            Err(err) => err,
613        };
614        assert!(err.to_string().contains("duplicate tool result projector"));
615        assert!(err.to_string().contains("projector-a"));
616        assert!(err.to_string().contains("projector-b"));
617    }
618}