1use std::future::Future;
2use std::sync::Arc;
3
4use crate::runtime::AssembledTurn;
5use crate::{
6 MessageRole, ProtocolTurnOptions, SessionPolicy, ToolDefinition, ToolManifest, ToolProvider,
7 ToolResult, TurnInput,
8};
9
10pub use lash_sansio::{
11 CheckpointKind, PluginMessage, PluginRuntimeEvent, PromptContribution, ToolCatalogContribution,
12};
13
14mod actions;
15mod error;
16mod history;
17mod hooks;
18mod protocol;
19mod registrar;
20mod registry;
21pub mod runtime_host;
22mod runtime_impl;
23mod services;
24mod session_obj;
25mod session_types;
26mod snapshot;
27mod tool_catalog;
28mod trigger_registry;
29
30pub(crate) use actions::{
31 ErasedPluginCommandOutcome, ErasedPluginTaskOutcome, PluginCommandHandler,
32 PluginCommandInvokeFuture, PluginQueryHandler, PluginQueryInvokeFuture, PluginTaskHandler,
33 PluginTaskInvokeFuture, RegisteredPluginCommand, RegisteredPluginQuery, RegisteredPluginTask,
34};
35pub use actions::{
36 PluginCommand, PluginCommandContext, PluginCommandOutcome, PluginCommandReceipt,
37 PluginOperation, PluginOperationDef, PluginOperationFailure, PluginOperationFuture,
38 PluginOperationKind, PluginQuery, PluginQueryContext, PluginRuntimeDirective, PluginTask,
39 PluginTaskContext, PluginTaskOutcome, PluginTaskReceipt, ProcessReadService, SessionParam,
40 SessionReadService, plugin_operation_def,
41};
42pub use error::PluginError;
43pub use history::{
44 CompactionContext, ContextCompaction, ContextCompactor, ContextError, SessionReadView,
45 TurnContextTransform, TurnTransformContext,
46};
47pub use hooks::{
48 AfterToolCallHook, AfterTurnHook, AssistantResponseHook, AssistantResponseHookContext,
49 AssistantResponseTransform, AssistantStreamHook, AssistantStreamHookContext,
50 AssistantStreamTransform, BeforeToolCallHook, BeforeTurnHook, CheckpointHook,
51 CheckpointHookContext, PluginFuture, PluginLifecycleEvent, PluginLifecycleEventHook,
52 PluginLifecycleFuture, PluginSessionTask, PromptContributor, PromptHookContext,
53 SessionConfigChangedContext, SessionConfigMutator, SessionStateChangedContext,
54 ToolCallHookContext, ToolCatalogContributor, ToolResultHookContext,
55 ToolResultProjectionContext, ToolResultProjector, TurnHookContext, TurnResultHookContext,
56 TurnResultSummary,
57};
58pub use protocol::{
59 AssistantProseProjectorPlugin, CodeExecutorPlugin, PluginOptions, ProtocolBeforeLlmCallContext,
60 ProtocolDriverPlugin, ProtocolLlmCallAction, ProtocolRuntimeContext, ProtocolSessionContext,
61 ProtocolSessionPlugin,
62};
63pub use registrar::{
64 ContextRegistrations, ExecutionRegistrations, OutputRegistrations,
65 PluginOperationRegistrations, PluginRegistrar, PromptRegistrations, ProtocolRegistrations,
66 SessionRegistrations, ToolCallRegistrations, ToolCatalogRegistrations, ToolRegistrations,
67 ToolResultRegistrations, TriggerEventRegistrations, TurnRegistrations,
68};
69pub(crate) use registrar::{PluginContributions, RegisteredHook};
70pub use registry::{
71 PluginExtensionContribution, PluginExtensions, PluginFactory, PluginSessionContext, PluginSpec,
72 PluginSpecBuilder, PluginSpecFactory, SessionPlugin, SessionReadyContext, StaticPluginFactory,
73};
74pub use runtime_host::{
75 AppendSessionNodesRequest, AppendSessionNodesResult, DirectCompletion, DirectLlmCompletion,
76 SessionGraphService, SessionLifecycleService, SessionStateService, SessionTurnInput,
77 SessionTurnRequest,
78};
79pub use runtime_impl::{PluginHost, SessionAuthorityContext};
80#[cfg(any(test, feature = "testing"))]
81pub(crate) use services::NoopSessionManager;
82pub use services::{PersistentRuntimeServices, PluginOperationInvokeError, RuntimeServices};
83pub use session_obj::PluginSession;
84pub use session_types::{
85 AgentFrameAssignment, AgentFrameId, AgentFrameReason, AgentFrameRecord, AgentFrameStatus,
86 OpenAgentFrameRequest, OpenAgentFrameResult, PluginOwned, SessionAppendNode,
87 SessionContextOverlay, SessionCreateRequest, SessionHandle, SessionPluginSource,
88 SessionRelation, SessionSnapshot, SessionStartPoint, SessionToolAccess, SubagentSessionContext,
89};
90pub(crate) use snapshot::{InMemorySnapshotReader, InMemorySnapshotWriter};
91pub use snapshot::{
92 PluginSessionSnapshot, PluginSnapshotArtifact, PluginSnapshotEntry, PluginSnapshotMeta,
93 SnapshotReader, SnapshotWriter,
94};
95pub use tool_catalog::{
96 CheckpointApplication, PluginAbort, PluginDirective, PrepareTurnRequest, ToolCatalogContext,
97 TurnFinalization, TurnPreparation,
98};
99pub(crate) use tool_catalog::{emit_plugin_runtime_events, plugin_runtime_session_events};
100pub(crate) fn builtin_plugin_factories() -> Vec<Arc<dyn PluginFactory>> {
101 let factories: Vec<Arc<dyn PluginFactory>> =
105 vec![Arc::new(trigger_registry::TriggerResourcePluginFactory)];
106 #[cfg(not(test))]
107 return factories;
108
109 #[cfg(test)]
110 {
111 factories
112 .into_iter()
113 .chain(crate::testing::test_standard_protocol_factories())
114 .collect()
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use schemars::JsonSchema;
121 use serde::{Deserialize, Serialize};
122 use serde_json::json;
123
124 use super::*;
125 use crate::{SessionSnapshot, ToolDefinition};
126
127 struct MockToolProvider;
128
129 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
130 struct TypedEchoArgs {
131 value: String,
132 }
133
134 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
135 struct TypedEchoOutput {
136 value: String,
137 session_id: Option<String>,
138 }
139
140 struct TypedEchoOp;
141
142 impl PluginOperation for TypedEchoOp {
143 const NAME: &'static str = "mock.typed_echo";
144 const DESCRIPTION: &'static str = "typed echo";
145 const SESSION_PARAM: SessionParam = SessionParam::Optional;
146 type Args = TypedEchoArgs;
147 type Output = TypedEchoOutput;
148 }
149
150 impl PluginQuery for TypedEchoOp {}
151
152 #[async_trait::async_trait]
153 impl ToolProvider for MockToolProvider {
154 fn tool_manifests(&self) -> Vec<ToolManifest> {
155 self.tool_definitions()
156 .into_iter()
157 .map(|tool| tool.manifest())
158 .collect()
159 }
160
161 fn resolve_contract(&self, name: &str) -> Option<Arc<crate::ToolContract>> {
162 self.tool_definitions()
163 .into_iter()
164 .find(|tool| tool.name() == name)
165 .map(|tool| Arc::new(tool.contract()))
166 }
167
168 async fn execute(&self, call: crate::ToolCall<'_>) -> ToolResult {
169 ToolResult::ok(call.args.clone())
170 }
171 }
172
173 impl MockToolProvider {
174 fn tool_definitions(&self) -> Vec<ToolDefinition> {
175 vec![ToolDefinition::raw(
176 "tool:mock_tool",
177 "mock_tool",
178 "",
179 json!({
180 "type": "object",
181 "properties": { "value": { "type": "string" } },
182 "required": ["value"],
183 "additionalProperties": false
184 }),
185 json!({ "type": "string" }),
186 )]
187 }
188 }
189
190 struct MockPluginFactory;
191
192 impl PluginFactory for MockPluginFactory {
193 fn id(&self) -> &'static str {
194 "mock"
195 }
196
197 fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
198 Ok(Arc::new(MockPlugin {
199 session_id: ctx.session_id.clone(),
200 }))
201 }
202 }
203
204 const TEST_EXTENSION_ID: &str = "test.extension";
205
206 struct ExtensionPluginFactory;
207
208 impl PluginFactory for ExtensionPluginFactory {
209 fn id(&self) -> &'static str {
210 "extension_resource"
211 }
212
213 fn extension_contributions(&self) -> Vec<PluginExtensionContribution> {
214 vec![PluginExtensionContribution::from_value(
215 TEST_EXTENSION_ID,
216 json!({ "resource": "clock.alarm" }),
217 )]
218 }
219
220 fn build(
221 &self,
222 _ctx: &PluginSessionContext,
223 ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
224 Ok(Arc::new(ExtensionPlugin))
225 }
226 }
227
228 struct ExtensionPlugin;
229
230 impl SessionPlugin for ExtensionPlugin {
231 fn id(&self) -> &'static str {
232 "extension_resource"
233 }
234
235 fn register(&self, _reg: &mut PluginRegistrar) -> Result<(), PluginError> {
236 Ok(())
237 }
238 }
239
240 struct MockPlugin {
241 session_id: String,
242 }
243
244 use crate::testing::MockSessionManager;
245
246 impl SessionPlugin for MockPlugin {
247 fn id(&self) -> &'static str {
248 "mock"
249 }
250
251 fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
252 reg.tools().provider(Arc::new(MockToolProvider))?;
253 reg.prompt().contribute(Arc::new(|_ctx| {
254 Box::pin(async move {
255 Ok(vec![
256 PromptContribution::guidance("Plugin Prompt", "Structured plugin prompt"),
257 PromptContribution::guidance("Dynamic Note", "dynamic note")
258 .with_priority(1),
259 ])
260 })
261 }));
262 let session_id = self.session_id.clone();
263 reg.operations().query(
264 PluginOperationDef {
265 name: "mock.echo".to_string(),
266 description: "echo".to_string(),
267 kind: PluginOperationKind::Query,
268 session_param: SessionParam::Optional,
269 input_schema: json!({}),
270 output_schema: json!({}),
271 },
272 Arc::new(move |ctx, args| {
273 let session_id = session_id.clone();
274 Box::pin(async move {
275 Ok(json!({
276 "session_id": ctx.session_id,
277 "plugin_session_id": session_id,
278 "args": args,
279 }))
280 })
281 }),
282 )?;
283 reg.operations()
284 .typed_query::<TypedEchoOp, _, _>(move |ctx, args| async move {
285 Ok(TypedEchoOutput {
286 value: args.value,
287 session_id: ctx.session_id,
288 })
289 })?;
290 Ok(())
291 }
292
293 fn snapshot(
294 &self,
295 _writer: &mut dyn SnapshotWriter,
296 ) -> Result<PluginSnapshotMeta, PluginError> {
297 Ok(PluginSnapshotMeta {
298 plugin_id: self.id().to_string(),
299 plugin_version: self.version().to_string(),
300 revision: self.snapshot_revision(),
301 state: Some(json!({"session_id": self.session_id})),
302 })
303 }
304 }
305
306 #[test]
307 fn plugin_host_collects_factory_extension_contributions() {
308 let host = PluginHost::new(vec![Arc::new(ExtensionPluginFactory)]);
309
310 assert_eq!(
311 host.extensions().payloads(TEST_EXTENSION_ID),
312 &[json!({ "resource": "clock.alarm" })]
313 );
314 let session = host.build_session("root", None).expect("session");
315 assert_eq!(
316 session.extensions().payloads(TEST_EXTENSION_ID),
317 &[json!({ "resource": "clock.alarm" })]
318 );
319 }
320
321 #[test]
322 fn declared_triggers_enter_session_catalog() {
323 struct TriggerEventOnlyFactory;
324
325 impl PluginFactory for TriggerEventOnlyFactory {
326 fn id(&self) -> &'static str {
327 "trigger_only"
328 }
329
330 fn build(
331 &self,
332 _ctx: &PluginSessionContext,
333 ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
334 Ok(Arc::new(TriggerEventOnlyPlugin))
335 }
336 }
337
338 struct TriggerEventOnlyPlugin;
339
340 impl SessionPlugin for TriggerEventOnlyPlugin {
341 fn id(&self) -> &'static str {
342 "trigger_only"
343 }
344
345 fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
346 reg.triggers().declare(crate::TriggerEvent::new(
347 "Button",
348 "ui.button",
349 "pressed",
350 crate::LashSchema::any(),
351 ))
352 }
353 }
354
355 let host = PluginHost::new(vec![Arc::new(TriggerEventOnlyFactory)]);
356
357 let session = host.build_session("root", None).expect("session");
358 assert!(
359 session
360 .triggers()
361 .get("Button", "ui.button", "pressed")
362 .is_some()
363 );
364 let event = session
365 .triggers()
366 .get("Button", "ui.button", "pressed")
367 .expect("button event");
368 assert_eq!(event.source_type(), "ui.button.pressed");
369 }
370
371 #[tokio::test]
372 async fn session_collects_tools_and_prompts() {
373 let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
374 let session = host.build_session("root", None).expect("session");
375 let tool_names = session
376 .tools()
377 .tool_manifests()
378 .into_iter()
379 .map(|manifest| manifest.name)
380 .collect::<std::collections::BTreeSet<_>>();
381 assert!(tool_names.contains("mock_tool"));
382 assert!(tool_names.contains("batch"));
383 let contributions = session
384 .collect_prompt_contributions(PromptHookContext {
385 session_id: "root".to_string(),
386 sessions: Arc::new(MockSessionManager::default()),
387 state: SessionReadView::from_snapshot(&SessionSnapshot::default()),
388 protocol_turn_options: ProtocolTurnOptions::default(),
389 turn_context: crate::TurnContext::default(),
390 })
391 .await
392 .expect("prompt contributions");
393 assert_eq!(
394 contributions,
395 vec![
396 PromptContribution::guidance("Plugin Prompt", "Structured plugin prompt"),
397 PromptContribution::guidance("Dynamic Note", "dynamic note").with_priority(1),
398 ]
399 );
400 }
401
402 #[tokio::test]
403 async fn external_query_defaults_to_current_session_when_requested() {
404 let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
405 let session = host.build_session("root", None).expect("session");
406 let (_plugin_id, result) = session
407 .query_plugin(
408 "mock.echo",
409 json!({"ok":true}),
410 None,
411 true,
412 Arc::new(NoopSessionManager),
413 Arc::new(NoopSessionManager),
414 )
415 .await
416 .expect("invoke");
417 assert_eq!(
418 result.get("session_id").and_then(|v| v.as_str()),
419 Some("root")
420 );
421 }
422
423 #[tokio::test]
424 async fn plugin_query_generates_schema_and_invokes_typed_output() {
425 let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
426 let session = host.build_session("root", None).expect("session");
427
428 let def = session
429 .plugin_operations()
430 .into_iter()
431 .find(|def| def.name == TypedEchoOp::NAME)
432 .expect("typed op definition");
433 assert_eq!(def.kind, PluginOperationKind::Query);
434 assert_eq!(def.session_param, SessionParam::Optional);
435 let value_type = def
436 .input_schema
437 .pointer("/schema/properties/value/type")
438 .or_else(|| def.input_schema.pointer("/properties/value/type"))
439 .and_then(serde_json::Value::as_str);
440 assert_eq!(value_type, Some("string"));
441
442 let (_plugin_id, output) = session
443 .query_plugin(
444 TypedEchoOp::NAME,
445 serde_json::to_value(TypedEchoArgs {
446 value: "hello".to_string(),
447 })
448 .unwrap(),
449 None,
450 true,
451 Arc::new(NoopSessionManager),
452 Arc::new(NoopSessionManager),
453 )
454 .await
455 .expect("typed invoke");
456 let output: TypedEchoOutput = serde_json::from_value(output).unwrap();
457 assert_eq!(output.value, "hello");
458 assert_eq!(output.session_id.as_deref(), Some("root"));
459 }
460
461 #[test]
462 fn plugin_operation_rejects_duplicate_names() {
463 struct DuplicatePlugin;
464
465 impl SessionPlugin for DuplicatePlugin {
466 fn id(&self) -> &'static str {
467 "duplicate"
468 }
469
470 fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
471 reg.operations()
472 .typed_query::<TypedEchoOp, _, _>(move |ctx, args| async move {
473 Ok(TypedEchoOutput {
474 value: args.value,
475 session_id: ctx.session_id,
476 })
477 })?;
478 reg.operations()
479 .typed_query::<TypedEchoOp, _, _>(move |ctx, args| async move {
480 Ok(TypedEchoOutput {
481 value: args.value,
482 session_id: ctx.session_id,
483 })
484 })
485 }
486 }
487
488 struct DuplicateFactory;
489 impl PluginFactory for DuplicateFactory {
490 fn id(&self) -> &'static str {
491 "duplicate"
492 }
493
494 fn build(
495 &self,
496 _ctx: &PluginSessionContext,
497 ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
498 Ok(Arc::new(DuplicatePlugin))
499 }
500 }
501
502 let err =
503 match PluginHost::new(vec![Arc::new(DuplicateFactory)]).build_session("root", None) {
504 Ok(_) => panic!("duplicate typed plugin operation should fail"),
505 Err(err) => err,
506 };
507 assert!(err.to_string().contains("duplicate plugin operation name"));
508 }
509
510 #[tokio::test]
511 async fn typed_external_query_errors_on_invalid_output() {
512 struct BadOp;
513 impl PluginOperation for BadOp {
514 const NAME: &'static str = "mock.echo";
515 const DESCRIPTION: &'static str = "bad typed projection over raw op";
516 const SESSION_PARAM: SessionParam = SessionParam::Optional;
517 type Args = TypedEchoArgs;
518 type Output = TypedEchoOutput;
519 }
520 impl PluginQuery for BadOp {}
521
522 let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
523 let session = host.build_session("root", None).expect("session");
524 let (_plugin_id, output) = session
525 .query_plugin(
526 BadOp::NAME,
527 serde_json::to_value(TypedEchoArgs {
528 value: "hello".to_string(),
529 })
530 .unwrap(),
531 None,
532 true,
533 Arc::new(NoopSessionManager),
534 Arc::new(NoopSessionManager),
535 )
536 .await
537 .expect("raw query");
538 let err = serde_json::from_value::<TypedEchoOutput>(output)
539 .expect_err("raw output shape should not match typed output");
540 assert!(err.to_string().contains("missing field"));
541 }
542
543 #[tokio::test]
544 async fn plugin_session_queries_registered_session() {
545 let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
546 let session = host.build_session("root", None).expect("session");
547
548 let (_plugin_id, result) = session
549 .query_plugin(
550 "mock.echo",
551 json!({"ok":true}),
552 Some("root".to_string()),
553 false,
554 Arc::new(NoopSessionManager),
555 Arc::new(NoopSessionManager),
556 )
557 .await
558 .expect("invoke");
559 assert_eq!(
560 result.get("session_id").and_then(|v| v.as_str()),
561 Some("root")
562 );
563 assert_eq!(
564 result.get("plugin_session_id").and_then(|v| v.as_str()),
565 Some("root")
566 );
567 }
568
569 #[tokio::test]
570 async fn plugin_session_queries_forked_session() {
571 let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
572 let root = host.build_session("root", None).expect("root");
573 let child = root.fork_for_session("child").expect("child");
574
575 let (_plugin_id, result) = child
576 .query_plugin(
577 "mock.echo",
578 json!({"ok":true}),
579 Some("child".to_string()),
580 false,
581 Arc::new(NoopSessionManager),
582 Arc::new(NoopSessionManager),
583 )
584 .await
585 .expect("invoke");
586 assert_eq!(
587 result.get("session_id").and_then(|v| v.as_str()),
588 Some("child")
589 );
590 assert_eq!(
591 result.get("plugin_session_id").and_then(|v| v.as_str()),
592 Some("child")
593 );
594
595 drop(child);
596 }
597
598 #[test]
599 fn plugin_host_unregisters_sessions() {
600 let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
601 let _session = host.build_session("root", None).expect("session");
602 assert!(host.session("root").is_ok());
603 host.unregister_session("root").expect("unregister");
604 match host.session("root") {
605 Err(PluginOperationInvokeError::UnknownSession(id)) => assert_eq!(id, "root"),
606 Ok(_) => panic!("expected missing session"),
607 Err(other) => panic!("unexpected error: {other}"),
608 }
609 }
610
611 #[test]
612 fn snapshot_round_trip_preserves_plugin_entries() {
613 let host = PluginHost::new(vec![Arc::new(MockPluginFactory)]);
614 let session = host.build_session("root", None).expect("session");
615 let snapshot = session.snapshot().expect("snapshot");
616 assert!(snapshot.plugins.contains_key("mock"));
617 let restored = host
618 .build_session("child", Some(&snapshot))
619 .expect("restored");
620 let restored_snapshot = restored.snapshot().expect("snapshot");
621 assert!(restored_snapshot.plugins.contains_key("mock"));
622 }
623
624 #[test]
625 fn runtime_services_are_backed_by_plugin_sessions() {
626 let host = PluginHost::new(vec![Arc::new(StaticPluginFactory::new(
627 "mock_tool",
628 PluginSpec::new()
629 .with_tool_provider(Arc::new(MockToolProvider) as Arc<dyn ToolProvider>),
630 ))]);
631 let services = RuntimeServices::new(host.build_session("root", None).expect("session"));
632 assert_eq!(services.plugins.session_id(), "root");
633 assert!(
634 services
635 .plugins
636 .tools()
637 .tool_manifests()
638 .iter()
639 .any(|tool| tool.name == "mock_tool")
640 );
641 }
642
643 struct ProjectorPluginFactory {
644 plugin_id: &'static str,
645 }
646
647 impl PluginFactory for ProjectorPluginFactory {
648 fn id(&self) -> &'static str {
649 self.plugin_id
650 }
651
652 fn build(
653 &self,
654 _ctx: &PluginSessionContext,
655 ) -> Result<Arc<dyn SessionPlugin>, PluginError> {
656 Ok(Arc::new(ProjectorPlugin {
657 plugin_id: self.plugin_id,
658 }))
659 }
660 }
661
662 struct ProjectorPlugin {
663 plugin_id: &'static str,
664 }
665
666 impl SessionPlugin for ProjectorPlugin {
667 fn id(&self) -> &'static str {
668 self.plugin_id
669 }
670
671 fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
672 reg.tool_results().projector(Arc::new(|ctx| {
673 Box::pin(async move {
674 Ok(crate::ModelToolReturn::from_output(
675 ctx.call_id,
676 ctx.tool_name,
677 &ctx.output,
678 ))
679 })
680 }))
681 }
682 }
683
684 #[test]
685 fn duplicate_tool_result_projectors_are_rejected() {
686 let host = PluginHost::new(vec![
687 Arc::new(ProjectorPluginFactory {
688 plugin_id: "projector-a",
689 }),
690 Arc::new(ProjectorPluginFactory {
691 plugin_id: "projector-b",
692 }),
693 ]);
694 let err = match host.build_session("root", None) {
695 Ok(_) => panic!("duplicate projector"),
696 Err(err) => err,
697 };
698 assert!(err.to_string().contains("duplicate tool result projector"));
699 assert!(err.to_string().contains("projector-a"));
700 assert!(err.to_string().contains("projector-b"));
701 }
702}