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 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}