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    /// Cached kernel metadata — stored here to avoid Box::leak in metadata()
153    kernel_metadata: KernelPluginMetadata,
154    /// Current plugin state
155    state: RwLock<RhaiPluginState>,
156    /// Plugin context
157    plugin_context: RwLock<Option<PluginContext>>,
158    /// Last modification time (for hot reload)
159    last_modified: u64,
160    /// Cached script content
161    cached_content: String,
162}
163
164impl RhaiPlugin {
165    /// Get last modification time
166    pub fn last_modified(&self) -> u64 {
167        self.last_modified
168    }
169
170    /// Create a new Rhai plugin from config
171    pub async fn new(config: RhaiPluginConfig) -> RhaiPluginResult<Self> {
172        let content = config.source.get_content().await?;
173        let last_modified = std::time::SystemTime::now()
174            .duration_since(std::time::UNIX_EPOCH)
175            .unwrap_or_default()
176            .as_secs();
177
178        // Create engine instance
179        let engine = Arc::new(RhaiScriptEngine::new(config.engine_config.clone())?);
180
181        // Parse metadata from script - TODO
182        let _script_metadata: HashMap<String, String> = HashMap::new();
183
184        // Initialize with default metadata
185        let mut metadata = PluginMetadata::default();
186        metadata.id = config.plugin_id.clone();
187
188        // Build kernel metadata once so metadata() can return a plain borrow
189        let kernel_metadata = KernelPluginMetadata::new(
190            &config.plugin_id,
191            &metadata.name,
192            PluginType::Tool,
193        );
194
195        // Create plugin
196        Ok(Self {
197            id: config.plugin_id.clone(),
198            config,
199            engine,
200            metadata,
201            kernel_metadata,
202            state: RwLock::new(RhaiPluginState::Unloaded),
203            plugin_context: RwLock::new(None),
204            last_modified,
205            cached_content: content,
206        })
207    }
208
209    /// Create a new Rhai plugin from file path
210    pub async fn from_file(plugin_id: &str, path: &PathBuf) -> RhaiPluginResult<Self> {
211        let config = RhaiPluginConfig::new_file(plugin_id, path);
212        Self::new(config).await
213    }
214
215    /// Create a new Rhai plugin from inline script content
216    pub async fn from_content(plugin_id: &str, content: &str) -> RhaiPluginResult<Self> {
217        let config = RhaiPluginConfig::new_inline(plugin_id, content);
218        Self::new(config).await
219    }
220
221    /// Reload plugin content
222    pub async fn reload(&mut self) -> RhaiPluginResult<()> {
223        let new_content = self.config.source.get_content().await?;
224        self.cached_content = new_content;
225
226        // Update last modified time from file metadata if available
227        self.last_modified = match &self.config.source {
228            RhaiPluginSource::File(path) => std::fs::metadata(path)?
229                .modified()?
230                .duration_since(std::time::UNIX_EPOCH)
231                .expect("时间转换失败")
232                .as_secs(),
233            _ => std::time::SystemTime::now()
234                .duration_since(std::time::UNIX_EPOCH)
235                .unwrap_or_default()
236                .as_secs(),
237        };
238
239        // Re-extract metadata
240        self.extract_metadata().await?;
241
242        Ok(())
243    }
244
245    /// Extract metadata from Rhai script
246    async fn extract_metadata(&mut self) -> RhaiPluginResult<()> {
247        // Compile and cache the script first to define global variables
248        let script_id = format!("{}_metadata", self.id);
249        if let Err(e) = self
250            .engine
251            .compile_and_cache(&script_id, "metadata", &self.cached_content)
252            .await
253        {
254            warn!("Failed to compile script for metadata extraction: {}", e);
255            return Ok(());
256        }
257
258        let context = mofa_extra::rhai::ScriptContext::new();
259
260        // Execute the script to define global variables
261        if let Ok(_) = self.engine.execute_compiled(&script_id, &context).await {
262            // Now try to extract variables by calling a snippet that returns them
263            // Try to extract plugin_name
264            if let Ok(result) = self.engine.execute("plugin_name", &context).await {
265                if result.success {
266                    if let Some(name) = result.value.as_str() {
267                        self.metadata.name = name.to_string();
268                    }
269                }
270            }
271
272            // Try to extract plugin_version
273            if let Ok(result) = self.engine.execute("plugin_version", &context).await {
274                if result.success {
275                    if let Some(version) = result.value.as_str() {
276                        self.metadata.version = version.to_string();
277                    }
278                }
279            }
280
281            // Try to extract plugin_description
282            if let Ok(result) = self.engine.execute("plugin_description", &context).await {
283                if result.success {
284                    if let Some(description) = result.value.as_str() {
285                        self.metadata.description = description.to_string();
286                    }
287                }
288            }
289        }
290
291        Ok(())
292    }
293
294    /// Call a script function if it exists
295    async fn call_script_function(
296        &self,
297        _function_name: &str,
298        _args: &[Dynamic],
299    ) -> RhaiPluginResult<Option<Dynamic>> {
300        // TODO: Implement proper function calling
301        // Current RhaiScriptEngine doesn't support calling specific functions,
302        // only executing entire scripts
303
304        Ok(None)
305    }
306}
307
308// ============================================================================
309// AgentPlugin Implementation for RhaiPlugin
310// ============================================================================
311
312#[async_trait::async_trait]
313impl AgentPlugin for RhaiPlugin {
314    fn metadata(&self) -> &KernelPluginMetadata {
315        &self.kernel_metadata
316    }
317
318    fn state(&self) -> PluginState {
319        // 在 Tokio 运行时内部使用 blocking 操作必须通过 block_in_place 或 spawn_blocking
320        tokio::task::block_in_place(|| {
321            let state = self.state.blocking_read();
322            state.clone().into()
323        })
324    }
325
326    async fn load(&mut self, ctx: &PluginContext) -> PluginResult<()> {
327        let mut state = self.state.write().await;
328        *state = RhaiPluginState::Loading;
329        drop(state);
330
331        // Save plugin context
332        *self.plugin_context.write().await = Some(ctx.clone());
333
334        // Extract metadata from script
335        self.extract_metadata().await?;
336
337        let mut state = self.state.write().await;
338        *state = RhaiPluginState::Loaded;
339        Ok(())
340    }
341
342    async fn init_plugin(&mut self) -> PluginResult<()> {
343        let mut state = self.state.write().await;
344        if *state != RhaiPluginState::Loaded {
345            return Err(anyhow::anyhow!("Plugin not loaded"));
346        }
347
348        *state = RhaiPluginState::Initializing;
349        drop(state);
350
351        // Call init function if exists
352        match self.call_script_function("init", &[]).await {
353            Ok(_) => {
354                info!("Rhai plugin {}: init function called", self.id);
355            }
356            Err(e) => {
357                warn!("Rhai plugin {}: init function failed: {}", self.id, e);
358            }
359        }
360
361        let mut state = self.state.write().await;
362        *state = RhaiPluginState::Running;
363        Ok(())
364    }
365
366    async fn start(&mut self) -> PluginResult<()> {
367        let mut state = self.state.write().await;
368        if *state != RhaiPluginState::Running && *state != RhaiPluginState::Paused {
369            return Err(anyhow::anyhow!("Plugin not ready to start"));
370        }
371
372        // Call start function if exists
373        match self.call_script_function("start", &[]).await {
374            Ok(_) => {
375                info!("Rhai plugin {}: start function called", self.id);
376            }
377            Err(e) => {
378                warn!("Rhai plugin {}: start function failed: {}", self.id, e);
379            }
380        }
381
382        *state = RhaiPluginState::Running;
383        Ok(())
384    }
385
386    async fn stop(&mut self) -> PluginResult<()> {
387        let mut state = self.state.write().await;
388        if *state != RhaiPluginState::Running {
389            return Err(anyhow::anyhow!("Plugin not running"));
390        }
391
392        // Call stop function if exists
393        match self.call_script_function("stop", &[]).await {
394            Ok(_) => {
395                info!("Rhai plugin {}: stop function called", self.id);
396            }
397            Err(e) => {
398                warn!("Rhai plugin {}: stop function failed: {}", self.id, e);
399            }
400        }
401
402        *state = RhaiPluginState::Paused;
403        Ok(())
404    }
405
406    async fn unload(&mut self) -> PluginResult<()> {
407        let mut state = self.state.write().await;
408        *state = RhaiPluginState::Unloaded;
409
410        // Call unload function if exists
411        match self.call_script_function("unload", &[]).await {
412            Ok(_) => {
413                info!("Rhai plugin {}: unload function called", self.id);
414            }
415            Err(e) => {
416                warn!("Rhai plugin {}: unload function failed: {}", self.id, e);
417            }
418        }
419
420        Ok(())
421    }
422
423    async fn execute(&mut self, input: String) -> PluginResult<String> {
424        let state = self.state.read().await;
425        if *state != RhaiPluginState::Running {
426            return Err(anyhow::anyhow!("Plugin not running"));
427        }
428        drop(state);
429
430        // Create context with input
431        let mut context = ScriptContext::new();
432        context = context.with_variable("input", input.clone())?;
433
434        // Compile and cache the script first
435        let script_id = format!("{}_exec", self.id);
436        self.engine
437            .compile_and_cache(&script_id, "execute", &self.cached_content)
438            .await?;
439
440        // Try to call the execute function with the input
441        match self
442            .engine
443            .call_function::<serde_json::Value>(
444                &script_id,
445                "execute",
446                vec![serde_json::json!(input)],
447                &context,
448            )
449            .await
450        {
451            Ok(result) => {
452                info!(
453                    "Rhai plugin {} executed successfully via call_function",
454                    self.id
455                );
456                Ok(serde_json::to_string_pretty(&result)?)
457            }
458            Err(e) => {
459                warn!(
460                    "Failed to call execute function: {}, falling back to direct execution",
461                    e
462                );
463
464                // Fallback: execute the script directly
465                let result = self.engine.execute(&self.cached_content, &context).await?;
466
467                if !result.success {
468                    return Err(anyhow::anyhow!(
469                        "Script execution failed: {:?}",
470                        result.error
471                    ));
472                }
473
474                Ok(serde_json::to_string_pretty(&result.value)?)
475            }
476        }
477    }
478
479    fn stats(&self) -> HashMap<String, serde_json::Value> {
480        HashMap::new() // TODO: Implement stats
481    }
482
483    fn as_any(&self) -> &dyn Any {
484        self
485    }
486
487    fn as_any_mut(&mut self) -> &mut dyn Any {
488        self
489    }
490
491    fn into_any(self: Box<Self>) -> Box<dyn Any> {
492        self
493    }
494}
495
496// ============================================================================
497// Tests
498// ============================================================================
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    static TEST_PLUGIN_SCRIPT: &str = r#"
505        let plugin_name = "test_rhai_plugin";
506        let plugin_version = "1.0.0";
507        let plugin_description = "Test Rhai plugin";
508
509        fn init() {
510            print("Test plugin initialized");
511        }
512
513        fn execute(input) {
514            "Hello from Rhai plugin! You said: " + input
515        }
516    "#;
517
518    #[tokio::test]
519    async fn test_rhai_plugin_from_content() {
520        let plugin = RhaiPlugin::from_content("test-plugin", TEST_PLUGIN_SCRIPT)
521            .await
522            .unwrap();
523
524        assert_eq!(plugin.id, "test-plugin");
525        // Note: metadata extraction happens during load(), not during creation
526        // After load(), metadata should be extracted from the script
527        // For now, verify the plugin was created successfully
528        assert!(!plugin.cached_content.is_empty());
529    }
530
531    #[tokio::test]
532    async fn test_rhai_plugin_lifecycle() {
533        let mut plugin = RhaiPlugin::from_content("test-plugin", TEST_PLUGIN_SCRIPT)
534            .await
535            .unwrap();
536
537        let ctx = PluginContext::default();
538        plugin.load(&ctx).await.unwrap();
539        assert!(matches!(
540            *plugin.state.read().await,
541            RhaiPluginState::Loaded
542        ));
543
544        plugin.init_plugin().await.unwrap();
545        assert!(matches!(
546            *plugin.state.read().await,
547            RhaiPluginState::Running
548        ));
549
550        plugin.stop().await.unwrap();
551        assert!(matches!(
552            *plugin.state.read().await,
553            RhaiPluginState::Paused
554        ));
555
556        plugin.start().await.unwrap();
557        assert!(matches!(
558            *plugin.state.read().await,
559            RhaiPluginState::Running
560        ));
561
562        plugin.unload().await.unwrap();
563        assert!(matches!(
564            *plugin.state.read().await,
565            RhaiPluginState::Unloaded
566        ));
567    }
568
569    #[tokio::test]
570    async fn test_rhai_plugin_execute() {
571        let mut plugin = RhaiPlugin::from_content("test-plugin", TEST_PLUGIN_SCRIPT)
572            .await
573            .unwrap();
574
575        let ctx = PluginContext::default();
576        plugin.load(&ctx).await.unwrap();
577        plugin.init_plugin().await.unwrap();
578
579        let result = plugin.execute("Hello World!".to_string()).await.unwrap();
580        // Result should be the string returned by execute function
581        // Note: The result is JSON serialized, so it will be a quoted string
582        println!("Execute result: {}", result);
583
584        // The execute function returns a string, which gets JSON serialized
585        // So we expect the result to be a JSON string containing our message
586        assert!(
587            result.contains("Hello from Rhai plugin!") || result.contains("Hello World!"),
588            "Result should contain expected text, got: {}",
589            result
590        );
591
592        plugin.unload().await.unwrap();
593    }
594}