Skip to main content

mofa_plugins/rhai_runtime/
plugin.rs

1//! Rhai Plugin Implementation
2//!
3//! Implements AgentPlugin for Rhai scripts
4
5use super::types::{PluginMetadata, RhaiPluginResult};
6use mofa_extra::rhai::{RhaiScriptEngine, ScriptContext, ScriptEngineConfig};
7use mofa_kernel::plugin::{
8    AgentPlugin, PluginContext, PluginMetadata as KernelPluginMetadata, PluginResult, PluginState,
9    PluginType,
10};
11use rhai::Dynamic;
12use std::any::Any;
13use std::collections::HashMap;
14use std::path::PathBuf;
15use std::sync::Arc;
16use tokio::sync::RwLock;
17use tracing::{error, info, warn};
18
19// ============================================================================
20// Rhai Plugin Configuration
21// ============================================================================
22
23/// Rhai plugin configuration
24#[derive(Debug, Clone)]
25pub struct RhaiPluginConfig {
26    /// Plugin script content or path
27    pub source: RhaiPluginSource,
28    /// Engine configuration
29    pub engine_config: ScriptEngineConfig,
30    /// Initial plugin context
31    pub initial_context: HashMap<String, Dynamic>,
32    /// Plugin dependencies
33    pub dependencies: Vec<String>,
34    /// Plugin ID
35    pub plugin_id: String,
36}
37
38impl Default for RhaiPluginConfig {
39    fn default() -> Self {
40        Self {
41            source: RhaiPluginSource::Inline("".to_string()),
42            engine_config: ScriptEngineConfig::default(),
43            initial_context: HashMap::new(),
44            dependencies: Vec::new(),
45            plugin_id: uuid::Uuid::now_v7().to_string(),
46        }
47    }
48}
49
50impl RhaiPluginConfig {
51    /// Create a new plugin config from inline script
52    pub fn new_inline(plugin_id: &str, script_content: &str) -> Self {
53        Self {
54            source: RhaiPluginSource::Inline(script_content.to_string()),
55            plugin_id: plugin_id.to_string(),
56            ..Default::default()
57        }
58    }
59
60    /// Create a new plugin config from file path
61    pub fn new_file(plugin_id: &str, file_path: &PathBuf) -> Self {
62        Self {
63            source: RhaiPluginSource::File(file_path.clone()),
64            plugin_id: plugin_id.to_string(),
65            ..Default::default()
66        }
67    }
68
69    /// With engine configuration
70    pub fn with_engine_config(mut self, config: ScriptEngineConfig) -> Self {
71        self.engine_config = config;
72        self
73    }
74
75    /// With initial context variable
76    pub fn with_context_var(mut self, key: &str, value: Dynamic) -> Self {
77        self.initial_context.insert(key.to_string(), value);
78        self
79    }
80}
81
82/// Rhai plugin source type
83#[derive(Debug, Clone)]
84pub enum RhaiPluginSource {
85    /// Inline script content
86    Inline(String),
87    /// File path to script
88    File(PathBuf),
89}
90
91impl RhaiPluginSource {
92    /// Get script content from source
93    pub async fn get_content(&self) -> RhaiPluginResult<String> {
94        match self {
95            RhaiPluginSource::Inline(content) => Ok(content.clone()),
96            RhaiPluginSource::File(path) => Ok(std::fs::read_to_string(path)?),
97        }
98    }
99}
100
101// ============================================================================
102// Rhai Plugin State
103// ============================================================================
104
105/// Rhai plugin state
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum RhaiPluginState {
108    /// Plugin is unloaded
109    Unloaded,
110    /// Plugin is loading
111    Loading,
112    /// Plugin is loaded but not initialized
113    Loaded,
114    /// Plugin is initializing
115    Initializing,
116    /// Plugin is running
117    Running,
118    /// Plugin is paused
119    Paused,
120    /// Plugin has encountered an error
121    Error(String),
122}
123
124impl From<RhaiPluginState> for PluginState {
125    fn from(state: RhaiPluginState) -> Self {
126        match state {
127            RhaiPluginState::Unloaded => PluginState::Unloaded,
128            RhaiPluginState::Loading => PluginState::Loading,
129            RhaiPluginState::Loaded => PluginState::Loaded,
130            RhaiPluginState::Initializing => PluginState::Loading,
131            RhaiPluginState::Running => PluginState::Running,
132            RhaiPluginState::Paused => PluginState::Paused,
133            RhaiPluginState::Error(err) => PluginState::Error(err),
134        }
135    }
136}
137
138// ============================================================================
139// Rhai Plugin
140// ============================================================================
141
142/// Rhai plugin wrapper
143pub struct RhaiPlugin {
144    /// Plugin ID
145    id: String,
146    /// Plugin configuration
147    config: RhaiPluginConfig,
148    /// Rhai script engine instance
149    engine: Arc<RhaiScriptEngine>,
150    /// Plugin metadata
151    metadata: PluginMetadata,
152    /// Current plugin state
153    state: RwLock<RhaiPluginState>,
154    /// Plugin context
155    plugin_context: RwLock<Option<PluginContext>>,
156    /// Last modification time (for hot reload)
157    last_modified: u64,
158    /// Cached script content
159    cached_content: String,
160}
161
162impl RhaiPlugin {
163    /// Get last modification time
164    pub fn last_modified(&self) -> u64 {
165        self.last_modified
166    }
167
168    /// Create a new Rhai plugin from config
169    pub async fn new(config: RhaiPluginConfig) -> RhaiPluginResult<Self> {
170        let content = config.source.get_content().await?;
171        let last_modified = std::time::SystemTime::now()
172            .duration_since(std::time::UNIX_EPOCH)
173            .unwrap_or_default()
174            .as_secs();
175
176        // Create engine instance
177        let engine = Arc::new(RhaiScriptEngine::new(config.engine_config.clone())?);
178
179        // Parse metadata from script - TODO
180        let _script_metadata: HashMap<String, String> = HashMap::new();
181
182        // Initialize with default metadata
183        let mut metadata = PluginMetadata::default();
184        metadata.id = config.plugin_id.clone();
185
186        // Create plugin
187        Ok(Self {
188            id: config.plugin_id.clone(),
189            config,
190            engine,
191            metadata,
192            state: RwLock::new(RhaiPluginState::Unloaded),
193            plugin_context: RwLock::new(None),
194            last_modified,
195            cached_content: content,
196        })
197    }
198
199    /// Create a new Rhai plugin from file path
200    pub async fn from_file(plugin_id: &str, path: &PathBuf) -> RhaiPluginResult<Self> {
201        let config = RhaiPluginConfig::new_file(plugin_id, path);
202        Self::new(config).await
203    }
204
205    /// Create a new Rhai plugin from inline script content
206    pub async fn from_content(plugin_id: &str, content: &str) -> RhaiPluginResult<Self> {
207        let config = RhaiPluginConfig::new_inline(plugin_id, content);
208        Self::new(config).await
209    }
210
211    /// Reload plugin content
212    pub async fn reload(&mut self) -> RhaiPluginResult<()> {
213        let new_content = self.config.source.get_content().await?;
214        self.cached_content = new_content;
215
216        // Update last modified time from file metadata if available
217        self.last_modified = match &self.config.source {
218            RhaiPluginSource::File(path) => std::fs::metadata(path)?
219                .modified()?
220                .duration_since(std::time::UNIX_EPOCH)
221                .expect("时间转换失败")
222                .as_secs(),
223            _ => std::time::SystemTime::now()
224                .duration_since(std::time::UNIX_EPOCH)
225                .unwrap_or_default()
226                .as_secs(),
227        };
228
229        // Re-extract metadata
230        self.extract_metadata().await?;
231
232        Ok(())
233    }
234
235    /// Extract metadata from Rhai script
236    async fn extract_metadata(&mut self) -> RhaiPluginResult<()> {
237        // Compile and cache the script first to define global variables
238        let script_id = format!("{}_metadata", self.id);
239        if let Err(e) = self.engine.compile_and_cache(&script_id, "metadata", &self.cached_content).await {
240            warn!("Failed to compile script for metadata extraction: {}", e);
241            return Ok(());
242        }
243
244        let context = mofa_extra::rhai::ScriptContext::new();
245
246        // Execute the script to define global variables
247        if let Ok(_) = self.engine.execute_compiled(&script_id, &context).await {
248            // Now try to extract variables by calling a snippet that returns them
249            // Try to extract plugin_name
250            if let Ok(result) = self.engine.execute("plugin_name", &context).await {
251                if result.success {
252                    if let Some(name) = result.value.as_str() {
253                        self.metadata.name = name.to_string();
254                    }
255                }
256            }
257
258            // Try to extract plugin_version
259            if let Ok(result) = self.engine.execute("plugin_version", &context).await {
260                if result.success {
261                    if let Some(version) = result.value.as_str() {
262                        self.metadata.version = version.to_string();
263                    }
264                }
265            }
266
267            // Try to extract plugin_description
268            if let Ok(result) = self.engine.execute("plugin_description", &context).await {
269                if result.success {
270                    if let Some(description) = result.value.as_str() {
271                        self.metadata.description = description.to_string();
272                    }
273                }
274            }
275        }
276
277        Ok(())
278    }
279
280    /// Call a script function if it exists
281    async fn call_script_function(
282        &self,
283        _function_name: &str,
284        _args: &[Dynamic],
285    ) -> RhaiPluginResult<Option<Dynamic>> {
286        // TODO: Implement proper function calling
287        // Current RhaiScriptEngine doesn't support calling specific functions,
288        // only executing entire scripts
289
290        Ok(None)
291    }
292}
293
294// ============================================================================
295// AgentPlugin Implementation for RhaiPlugin
296// ============================================================================
297
298#[async_trait::async_trait]
299impl AgentPlugin for RhaiPlugin {
300    fn metadata(&self) -> &KernelPluginMetadata {
301        // Return a static reference - this is a temporary fix
302        // In production, we should store KernelPluginMetadata in the struct
303        Box::leak(Box::new(KernelPluginMetadata::new(
304            &self.id,
305            &self.metadata.name,
306            PluginType::Tool,
307        )))
308    }
309
310    fn state(&self) -> PluginState {
311        // 在 Tokio 运行时内部使用 blocking 操作必须通过 block_in_place 或 spawn_blocking
312        tokio::task::block_in_place(|| {
313            let state = self.state.blocking_read();
314            state.clone().into()
315        })
316    }
317
318    async fn load(&mut self, ctx: &PluginContext) -> PluginResult<()> {
319        let mut state = self.state.write().await;
320        *state = RhaiPluginState::Loading;
321        drop(state);
322
323        // Save plugin context
324        *self.plugin_context.write().await = Some(ctx.clone());
325
326        // Extract metadata from script
327        self.extract_metadata().await?;
328
329        let mut state = self.state.write().await;
330        *state = RhaiPluginState::Loaded;
331        Ok(())
332    }
333
334    async fn init_plugin(&mut self) -> PluginResult<()> {
335        let mut state = self.state.write().await;
336        if *state != RhaiPluginState::Loaded {
337            return Err(anyhow::anyhow!("Plugin not loaded"));
338        }
339
340        *state = RhaiPluginState::Initializing;
341        drop(state);
342
343        // Call init function if exists
344        match self.call_script_function("init", &[]).await {
345            Ok(_) => {
346                info!("Rhai plugin {}: init function called", self.id);
347            }
348            Err(e) => {
349                warn!("Rhai plugin {}: init function failed: {}", self.id, e);
350            }
351        }
352
353        let mut state = self.state.write().await;
354        *state = RhaiPluginState::Running;
355        Ok(())
356    }
357
358    async fn start(&mut self) -> PluginResult<()> {
359        let mut state = self.state.write().await;
360        if *state != RhaiPluginState::Running && *state != RhaiPluginState::Paused {
361            return Err(anyhow::anyhow!("Plugin not ready to start"));
362        }
363
364        // Call start function if exists
365        match self.call_script_function("start", &[]).await {
366            Ok(_) => {
367                info!("Rhai plugin {}: start function called", self.id);
368            }
369            Err(e) => {
370                warn!("Rhai plugin {}: start function failed: {}", self.id, e);
371            }
372        }
373
374        *state = RhaiPluginState::Running;
375        Ok(())
376    }
377
378    async fn stop(&mut self) -> PluginResult<()> {
379        let mut state = self.state.write().await;
380        if *state != RhaiPluginState::Running {
381            return Err(anyhow::anyhow!("Plugin not running"));
382        }
383
384        // Call stop function if exists
385        match self.call_script_function("stop", &[]).await {
386            Ok(_) => {
387                info!("Rhai plugin {}: stop function called", self.id);
388            }
389            Err(e) => {
390                warn!("Rhai plugin {}: stop function failed: {}", self.id, e);
391            }
392        }
393
394        *state = RhaiPluginState::Paused;
395        Ok(())
396    }
397
398    async fn unload(&mut self) -> PluginResult<()> {
399        let mut state = self.state.write().await;
400        *state = RhaiPluginState::Unloaded;
401
402        // Call unload function if exists
403        match self.call_script_function("unload", &[]).await {
404            Ok(_) => {
405                info!("Rhai plugin {}: unload function called", self.id);
406            }
407            Err(e) => {
408                warn!("Rhai plugin {}: unload function failed: {}", self.id, e);
409            }
410        }
411
412        Ok(())
413    }
414
415    async fn execute(&mut self, input: String) -> PluginResult<String> {
416        let state = self.state.read().await;
417        if *state != RhaiPluginState::Running {
418            return Err(anyhow::anyhow!("Plugin not running"));
419        }
420        drop(state);
421
422        // Create context with input
423        let mut context = ScriptContext::new();
424        context = context.with_variable("input", input.clone())?;
425
426        // Compile and cache the script first
427        let script_id = format!("{}_exec", self.id);
428        self.engine.compile_and_cache(&script_id, "execute", &self.cached_content).await?;
429
430        // Try to call the execute function with the input
431        match self.engine.call_function::<serde_json::Value>(
432            &script_id,
433            "execute",
434            vec![serde_json::json!(input)],
435            &context,
436        ).await {
437            Ok(result) => {
438                info!("Rhai plugin {} executed successfully via call_function", self.id);
439                Ok(serde_json::to_string_pretty(&result)?)
440            }
441            Err(e) => {
442                warn!("Failed to call execute function: {}, falling back to direct execution", e);
443
444                // Fallback: execute the script directly
445                let result = self.engine.execute(&self.cached_content, &context).await?;
446
447                if !result.success {
448                    return Err(anyhow::anyhow!("Script execution failed: {:?}", result.error));
449                }
450
451                Ok(serde_json::to_string_pretty(&result.value)?)
452            }
453        }
454    }
455
456    fn stats(&self) -> HashMap<String, serde_json::Value> {
457        HashMap::new() // TODO: Implement stats
458    }
459
460    fn as_any(&self) -> &dyn Any {
461        self
462    }
463
464    fn as_any_mut(&mut self) -> &mut dyn Any {
465        self
466    }
467
468    fn into_any(self: Box<Self>) -> Box<dyn Any> {
469        self
470    }
471}
472
473// ============================================================================
474// Tests
475// ============================================================================
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    static TEST_PLUGIN_SCRIPT: &str = r#"
482        let plugin_name = "test_rhai_plugin";
483        let plugin_version = "1.0.0";
484        let plugin_description = "Test Rhai plugin";
485
486        fn init() {
487            print("Test plugin initialized");
488        }
489
490        fn execute(input) {
491            "Hello from Rhai plugin! You said: " + input
492        }
493    "#;
494
495    #[tokio::test]
496    async fn test_rhai_plugin_from_content() {
497        let plugin = RhaiPlugin::from_content("test-plugin", TEST_PLUGIN_SCRIPT)
498            .await
499            .unwrap();
500
501        assert_eq!(plugin.id, "test-plugin");
502        // Note: metadata extraction happens during load(), not during creation
503        // After load(), metadata should be extracted from the script
504        // For now, verify the plugin was created successfully
505        assert!(!plugin.cached_content.is_empty());
506    }
507
508    #[tokio::test]
509    async fn test_rhai_plugin_lifecycle() {
510        let mut plugin = RhaiPlugin::from_content("test-plugin", TEST_PLUGIN_SCRIPT)
511            .await
512            .unwrap();
513
514        let ctx = PluginContext::default();
515        plugin.load(&ctx).await.unwrap();
516        assert!(matches!(
517            *plugin.state.read().await,
518            RhaiPluginState::Loaded
519        ));
520
521        plugin.init_plugin().await.unwrap();
522        assert!(matches!(
523            *plugin.state.read().await,
524            RhaiPluginState::Running
525        ));
526
527        plugin.stop().await.unwrap();
528        assert!(matches!(
529            *plugin.state.read().await,
530            RhaiPluginState::Paused
531        ));
532
533        plugin.start().await.unwrap();
534        assert!(matches!(
535            *plugin.state.read().await,
536            RhaiPluginState::Running
537        ));
538
539        plugin.unload().await.unwrap();
540        assert!(matches!(
541            *plugin.state.read().await,
542            RhaiPluginState::Unloaded
543        ));
544    }
545
546    #[tokio::test]
547    async fn test_rhai_plugin_execute() {
548        let mut plugin = RhaiPlugin::from_content("test-plugin", TEST_PLUGIN_SCRIPT)
549            .await
550            .unwrap();
551
552        let ctx = PluginContext::default();
553        plugin.load(&ctx).await.unwrap();
554        plugin.init_plugin().await.unwrap();
555
556        let result = plugin.execute("Hello World!".to_string()).await.unwrap();
557        // Result should be the string returned by execute function
558        // Note: The result is JSON serialized, so it will be a quoted string
559        println!("Execute result: {}", result);
560
561        // The execute function returns a string, which gets JSON serialized
562        // So we expect the result to be a JSON string containing our message
563        assert!(result.contains("Hello from Rhai plugin!") || result.contains("Hello World!"),
564            "Result should contain expected text, got: {}", result);
565
566        plugin.unload().await.unwrap();
567    }
568}