Skip to main content

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}