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