pulse_system_types/plugin.rs
1//! Plugin trait — the shared contract for pulse-null plugins.
2//!
3//! Plugins are constructed via async factory functions, not through the trait.
4//! By the time a [`Plugin`] exists, it is fully initialized and ready for
5//! [`start()`](Plugin::start).
6//!
7//! # Factory Pattern
8//!
9//! Each plugin crate exports a factory function:
10//!
11//! ```rust,ignore
12//! pub async fn create(
13//! config: &serde_json::Value,
14//! ctx: &PluginContext,
15//! ) -> Result<Box<dyn Plugin>, Box<dyn Error + Send + Sync>>
16//! ```
17//!
18//! This avoids two-phase initialization — the plugin is either fully
19//! constructed or the factory returns an error.
20//!
21//! # Object Safety
22//!
23//! The trait is object-safe and designed for `Box<dyn Plugin>`. Async methods
24//! use `Pin<Box<dyn Future>>` instead of `async_trait`.
25
26use std::any::Any;
27use std::future::Future;
28use std::path::PathBuf;
29use std::pin::Pin;
30use std::sync::Arc;
31
32use crate::llm::LmProvider;
33use crate::tool::Tool;
34use crate::{HealthStatus, PluginMeta, ScheduledTask, SetupPrompt};
35
36/// Async result type for plugin lifecycle operations.
37pub type PluginResult<'a> =
38 Pin<Box<dyn Future<Output = Result<(), Box<dyn std::error::Error + Send + Sync>>> + Send + 'a>>;
39
40/// Context passed to plugin factories during construction.
41///
42/// Contains everything a plugin needs to initialize:
43/// filesystem root, pulse identity, and LLM access.
44pub struct PluginContext {
45 /// Root directory of the pulse (e.g., `/home/synth/pulse-null/synth`).
46 pub pulse_root: PathBuf,
47 /// Pulse name from config (e.g., `"Echo"`).
48 pub pulse_name: String,
49 /// LLM provider for plugin use (summarization, analysis, etc.).
50 pub provider: Arc<dyn LmProvider>,
51}
52
53/// Declares what role a plugin fills in the system.
54///
55/// Some roles are constrained:
56/// - [`Memory`](PluginRole::Memory): exactly one required.
57/// - All others: zero or more.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59pub enum PluginRole {
60 /// Persistent memory system. **Required — exactly one.**
61 Memory,
62 /// Pipeline state monitoring.
63 Pipeline,
64 /// Cognitive signal monitoring.
65 Cognitive,
66 /// Outcome tracking.
67 Outcome,
68 /// Communication interface (HTTP, voice, chat, etc.).
69 Interface,
70 /// General-purpose extension. No constraints.
71 Extension,
72}
73
74/// The core plugin contract.
75///
76/// Plugins are constructed via async factory functions — by the time this
77/// trait is available, the plugin is fully initialized and ready for
78/// [`start()`](Plugin::start).
79///
80/// # Dyn-compatible
81///
82/// Uses `Pin<Box<dyn Future>>` for async methods. No `async_trait` dependency.
83///
84/// # Extension via `as_any()`
85///
86/// Host-specific capabilities (e.g., HTTP routes via axum) are not part of
87/// this trait. Plugins that expose such capabilities implement [`as_any()`](Plugin::as_any)
88/// to allow the host to downcast to the concrete type.
89pub trait Plugin: Send + Sync {
90 /// Plugin identity (name, version, description).
91 fn meta(&self) -> PluginMeta;
92
93 /// What role this plugin fills in the system.
94 fn role(&self) -> PluginRole;
95
96 /// Start the plugin. Called once after construction.
97 fn start(&mut self) -> PluginResult<'_>;
98
99 /// Stop the plugin gracefully.
100 fn stop(&mut self) -> PluginResult<'_>;
101
102 /// Report current health.
103 fn health(&self) -> Pin<Box<dyn Future<Output = HealthStatus> + Send + '_>>;
104
105 /// Optional: contribute scheduled tasks.
106 fn scheduled_tasks(&self) -> Vec<ScheduledTask> {
107 Vec::new()
108 }
109
110 /// Optional: setup wizard prompts for first-time configuration.
111 fn setup_prompts(&self) -> Vec<SetupPrompt> {
112 Vec::new()
113 }
114
115 /// Optional: contribute tools to the pulse's tool registry.
116 fn tools(&self) -> Vec<Box<dyn Tool>> {
117 Vec::new()
118 }
119
120 /// Downcast support for host-specific extensions.
121 ///
122 /// Plugins that expose capabilities beyond this trait (e.g., axum routes)
123 /// return `self` here so the host can downcast to the concrete type.
124 fn as_any(&self) -> &dyn Any;
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use crate::llm::{LlmResult, Message};
131
132 struct StubProvider;
133
134 impl LmProvider for StubProvider {
135 fn invoke(
136 &self,
137 _system_prompt: &str,
138 _messages: &[Message],
139 _max_tokens: u32,
140 _tools: Option<&[serde_json::Value]>,
141 ) -> LlmResult<'_> {
142 Box::pin(async { Err("stub provider".into()) })
143 }
144
145 fn name(&self) -> &str {
146 "stub"
147 }
148 }
149
150 #[test]
151 fn plugin_context_carries_pulse_identity() {
152 let ctx = PluginContext {
153 pulse_root: PathBuf::from("/home/synth/pulse-null/synth"),
154 pulse_name: "Synth".into(),
155 provider: Arc::new(StubProvider),
156 };
157 assert_eq!(
158 ctx.pulse_root,
159 PathBuf::from("/home/synth/pulse-null/synth")
160 );
161 assert_eq!(ctx.pulse_name, "Synth");
162 assert_eq!(ctx.provider.name(), "stub");
163 }
164
165 #[test]
166 fn plugin_role_equality() {
167 assert_eq!(PluginRole::Memory, PluginRole::Memory);
168 assert_ne!(PluginRole::Memory, PluginRole::Pipeline);
169 }
170
171 #[test]
172 fn plugin_role_is_copy() {
173 let role = PluginRole::Memory;
174 let copy = role;
175 assert_eq!(role, copy);
176 }
177
178 #[test]
179 fn plugin_role_debug() {
180 let debug = format!("{:?}", PluginRole::Interface);
181 assert_eq!(debug, "Interface");
182 }
183}