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