1use mlua::{Function, HookTriggers, Lua, MultiValue, Table, Value as LuaValue, VmState};
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4use std::collections::{BTreeMap, HashMap, HashSet};
5use std::fs;
6use std::io::{Read, Write};
7use std::path::{Component, Path, PathBuf};
8use std::process::{Command, Stdio};
9use std::sync::{Arc, Condvar, Mutex, OnceLock};
10use std::thread;
11use std::time::{Duration, Instant};
12
13use crate::dependency::manager::{DependencyManager, DependencyManagerConfig, ensure_directory};
14use crate::entry_descriptor::{RuntimeEntryDescriptor, RuntimeEntryParameterDescriptor};
15use crate::host::callbacks::{
16 RuntimeEntryRegistryDelta, RuntimeSkillLifecycleEvent, RuntimeSkillManagementAction,
17 RuntimeSkillManagementRequest, dispatch_skill_management_request,
18 try_has_skill_management_callback,
19};
20use crate::host::database::RuntimeDatabaseProviderCallbacks;
21use crate::lancedb_host::{LanceDbSkillBinding, LanceDbSkillHost, disabled_skill_status_json};
22use crate::lua_skill::{SkillMeta, validate_luaskills_identifier, validate_luaskills_version};
23use crate::runtime::config::{SkillConfigEntry, SkillConfigStore};
24use crate::runtime_context::{RuntimeClientInfo, RuntimeRequestContext};
25use crate::runtime_help::{
26 RuntimeHelpDetail, RuntimeHelpNodeDescriptor, RuntimeSkillHelpDescriptor,
27};
28use crate::runtime_logging::{error as log_error, info as log_info, warn as log_warn};
29use crate::runtime_options::{LuaInvocationContext, LuaRuntimeHostOptions, RuntimeSkillRoot};
30use crate::runtime_result::{
31 NON_STRING_TOOL_RESULT_ERROR, RuntimeInvocationResult, ToolOverflowMode,
32};
33use crate::skill::dependencies::SkillDependencyManifest;
34use crate::skill::manager::{
35 PreparedSkillApply, SkillApplyResult, SkillInstallRequest, SkillManager, SkillManagerConfig,
36 SkillOperationPlane, SkillUninstallOptions, SkillUninstallResult,
37 collect_effective_skill_instances_from_roots, resolve_declared_skill_instance_from_roots,
38 resolve_effective_skill_instance_from_roots,
39};
40use crate::sqlite_host::{
41 SqliteSkillBinding, SqliteSkillHost,
42 disabled_skill_status_json as disabled_sqlite_skill_status_json,
43};
44use crate::tool_cache::{ToolCacheConfig, configure_global_tool_cache, global_tool_cache};
45
46#[derive(Clone)]
51struct LoadedSkill {
52 meta: SkillMeta,
53 dir: std::path::PathBuf,
54 root_name: String,
55 lancedb_binding: Option<Arc<LanceDbSkillBinding>>,
56 sqlite_binding: Option<Arc<SqliteSkillBinding>>,
57 resolved_entry_names: HashMap<String, String>,
58}
59
60fn normalize_host_visible_path_text(rendered: &str) -> String {
63 #[cfg(windows)]
64 {
65 if let Some(stripped) = rendered.strip_prefix(r"\\?\UNC\") {
66 return format!(r"\\{}", stripped);
67 }
68 if let Some(stripped) = rendered.strip_prefix(r"\\?\") {
69 return stripped.to_string();
70 }
71 }
72 rendered.to_string()
73}
74
75fn render_host_visible_path(path: &Path) -> String {
78 normalize_host_visible_path_text(&path.to_string_lossy())
79}
80
81fn render_log_friendly_path(path: &Path) -> String {
84 render_host_visible_path(path)
85}
86
87fn normalize_runtime_root_path(path: &Path) -> PathBuf {
90 let mut normalized = PathBuf::new();
91 let mut can_pop_normal = false;
92 for component in path.components() {
93 match component {
94 Component::Prefix(prefix) => {
95 normalized.push(prefix.as_os_str());
96 can_pop_normal = false;
97 }
98 Component::RootDir => {
99 normalized.push(component.as_os_str());
100 can_pop_normal = false;
101 }
102 Component::CurDir => {}
103 Component::ParentDir => {
104 if can_pop_normal && normalized.pop() {
105 can_pop_normal = !matches!(
106 normalized.components().next_back(),
107 Some(Component::Prefix(_)) | Some(Component::RootDir) | None
108 );
109 } else if !path.is_absolute() {
110 normalized.push(component.as_os_str());
111 can_pop_normal = false;
112 }
113 }
114 Component::Normal(part) => {
115 normalized.push(part);
116 can_pop_normal = true;
117 }
118 }
119 }
120 normalized
121}
122
123#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
126pub struct LuaVmPoolConfig {
127 pub min_size: usize,
130 pub max_size: usize,
133 pub idle_ttl_secs: u64,
136}
137
138impl LuaVmPoolConfig {
139 fn normalized(self) -> Self {
142 let min_size = self.min_size.max(1);
143 let max_size = self.max_size.max(min_size);
144 let idle_ttl_secs = self.idle_ttl_secs.max(1);
145 Self {
146 min_size,
147 max_size,
148 idle_ttl_secs,
149 }
150 }
151}
152
153fn default_runlua_vm_pool_config() -> LuaVmPoolConfig {
156 LuaVmPoolConfig {
157 min_size: 1,
158 max_size: 4,
159 idle_ttl_secs: 60,
160 }
161}
162
163struct LuaVm {
166 lua: Lua,
167 last_used_at: Instant,
168}
169
170struct LuaVmPoolState {
173 available: Vec<LuaVm>,
174 total_count: usize,
175}
176
177struct LuaVmPool {
180 config: LuaVmPoolConfig,
181 state: Mutex<LuaVmPoolState>,
182 condvar: Condvar,
183}
184
185pub struct LuaEngine {
190 skills: HashMap<String, LoadedSkill>,
191 entry_registry: BTreeMap<String, ResolvedEntryTarget>,
192 pool: Arc<LuaVmPool>,
193 runlua_pool: Arc<LuaVmPool>,
194 skill_config_store: Arc<SkillConfigStore>,
195 lancedb_host: Option<Arc<LanceDbSkillHost>>,
196 sqlite_host: Option<Arc<SqliteSkillHost>>,
197 database_provider_callbacks: Arc<RuntimeDatabaseProviderCallbacks>,
198 host_options: Arc<LuaRuntimeHostOptions>,
199}
200
201#[derive(Debug, Clone)]
204struct ResolvedEntryTarget {
205 canonical_name: String,
208 skill_storage_key: String,
211 skill_id: String,
214 local_name: String,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct LuaEngineOptions {
223 pub pool_config: LuaVmPoolConfig,
226 pub host_options: LuaRuntimeHostOptions,
229}
230
231impl LuaEngineOptions {
232 pub fn new(pool_config: LuaVmPoolConfig, host_options: LuaRuntimeHostOptions) -> Self {
235 Self {
236 pool_config,
237 host_options,
238 }
239 }
240}
241
242impl LoadedSkill {
243 fn resolved_tool_name(&self, local_name: &str) -> Option<&str> {
246 self.resolved_entry_names
247 .get(local_name)
248 .map(String::as_str)
249 }
250}
251
252fn create_runtime_skill_management_bridge_fn(
255 lua: &Lua,
256 enabled: bool,
257 action: RuntimeSkillManagementAction,
258 function_name: &'static str,
259) -> mlua::Result<Function> {
260 let action_name = function_name.to_string();
261 lua.create_function(move |lua, input: LuaValue| {
262 if !enabled {
263 return Err(mlua::Error::runtime(format!(
264 "vulcan.runtime.skills.{} is disabled by host policy",
265 action_name
266 )));
267 }
268
269 let payload = lua_value_to_json(&input).map_err(|error| {
270 mlua::Error::runtime(format!("vulcan.runtime.skills.{}: {}", action_name, error))
271 })?;
272 let result = dispatch_skill_management_request(&RuntimeSkillManagementRequest {
273 action: action.clone(),
274 input: payload,
275 })
276 .map_err(|error| {
277 mlua::Error::runtime(format!("vulcan.runtime.skills.{}: {}", action_name, error))
278 })?;
279 json_value_to_lua(lua, &result)
280 })
281}
282
283fn lua_value_type_name(value: &LuaValue) -> &'static str {
286 match value {
287 LuaValue::Nil => "nil",
288 LuaValue::Boolean(_) => "boolean",
289 LuaValue::LightUserData(_) => "lightuserdata",
290 LuaValue::Integer(_) => "integer",
291 LuaValue::Number(_) => "number",
292 LuaValue::String(_) => "string",
293 LuaValue::Table(_) => "table",
294 LuaValue::Function(_) => "function",
295 LuaValue::Thread(_) => "thread",
296 LuaValue::UserData(_) => "userdata",
297 LuaValue::Error(_) => "error",
298 LuaValue::Other(_) => "other",
299 }
300}
301
302#[derive(Debug, Deserialize, Serialize)]
305struct RunLuaExecRequest {
306 #[serde(default)]
309 task: String,
310 #[serde(default)]
313 code: Option<String>,
314 #[serde(default)]
317 file: Option<String>,
318 #[serde(default = "default_runlua_exec_args")]
321 args: Value,
322 #[serde(default = "default_runlua_timeout_ms")]
325 timeout_ms: u64,
326 #[serde(default)]
329 caller_tool_name: Option<String>,
330}
331
332fn default_runlua_exec_args() -> Value {
335 Value::Object(serde_json::Map::new())
336}
337
338fn default_runlua_timeout_ms() -> u64 {
341 60_000
342}
343
344fn runlua_cwd_guard() -> &'static Mutex<()> {
347 static RUNLUA_CWD_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
348 RUNLUA_CWD_GUARD.get_or_init(|| Mutex::new(()))
349}
350
351fn build_luaexec_call_request_context() -> RuntimeRequestContext {
354 RuntimeRequestContext {
355 transport_name: Some("luaexec_call".to_string()),
356 session_id: Some("luaexec-call-internal".to_string()),
357 client_info: Some(RuntimeClientInfo {
358 kind: Some("runtime".to_string()),
359 name: Some("luaexec_call".to_string()),
360 version: Some("internal-runtime".to_string()),
361 }),
362 client_capabilities: json!({}),
363 }
364}
365
366#[derive(Debug)]
369struct RunLuaRenderedValue {
370 format: &'static str,
373 content: String,
376}
377
378fn looks_like_lua_debug_value(text: &str) -> bool {
381 ["table: 0x", "function: 0x", "thread: 0x", "userdata: 0x"]
382 .iter()
383 .any(|prefix| text.starts_with(prefix))
384}
385
386#[cfg(windows)]
389fn has_invalid_windows_path_syntax(text: &str) -> bool {
390 let trimmed = text.trim();
391 if trimmed.starts_with(r"\\?\") {
392 return false;
393 }
394
395 let first_char = trimmed.chars().next();
396 for (index, ch) in trimmed.char_indices() {
397 if ch.is_control() {
398 return true;
399 }
400 if matches!(ch, '<' | '>' | '"' | '|' | '?' | '*') {
401 return true;
402 }
403 if ch == ':' {
404 let is_drive_prefix =
405 index == 1 && first_char.map(|c| c.is_ascii_alphabetic()).unwrap_or(false);
406 if !is_drive_prefix {
407 return true;
408 }
409 }
410 }
411 false
412}
413
414fn require_string_arg(
417 value: LuaValue,
418 fn_name: &str,
419 param_name: &str,
420 allow_blank: bool,
421) -> mlua::Result<String> {
422 let raw = match value {
423 LuaValue::String(text) => text
424 .to_str()
425 .map_err(|_| {
426 mlua::Error::runtime(format!(
427 "{fn_name}: {param_name} must be a valid UTF-8 string"
428 ))
429 })?
430 .to_string(),
431 other => {
432 return Err(mlua::Error::runtime(format!(
433 "{fn_name}: {param_name} must be a string, got {}",
434 lua_value_type_name(&other)
435 )));
436 }
437 };
438
439 if !allow_blank && raw.trim().is_empty() {
440 return Err(mlua::Error::runtime(format!(
441 "{fn_name}: {param_name} must not be empty"
442 )));
443 }
444 if raw.contains('\0') {
445 return Err(mlua::Error::runtime(format!(
446 "{fn_name}: {param_name} must not contain NUL bytes"
447 )));
448 }
449 Ok(raw)
450}
451
452fn validate_path_text(text: &str, fn_name: &str, param_name: &str) -> mlua::Result<()> {
455 if looks_like_lua_debug_value(text) {
456 return Err(mlua::Error::runtime(format!(
457 "{fn_name}: {param_name} looks like a coerced Lua object string `{text}`"
458 )));
459 }
460
461 #[cfg(windows)]
462 if has_invalid_windows_path_syntax(text) {
463 return Err(mlua::Error::runtime(format!(
464 "{fn_name}: {param_name} contains invalid Windows path syntax"
465 )));
466 }
467
468 Ok(())
469}
470
471fn require_path_arg(value: LuaValue, fn_name: &str, param_name: &str) -> mlua::Result<String> {
474 let text = require_string_arg(value, fn_name, param_name, false)?;
475 validate_path_text(&text, fn_name, param_name)?;
476 Ok(text)
477}
478
479fn optional_u64_arg(value: LuaValue, fn_name: &str, param_name: &str) -> mlua::Result<Option<u64>> {
482 match value {
483 LuaValue::Nil => Ok(None),
484 LuaValue::Integer(v) if v >= 0 => Ok(Some(v as u64)),
485 LuaValue::Number(v) if v.is_finite() && v >= 0.0 && v.fract() == 0.0 => Ok(Some(v as u64)),
486 other => Err(mlua::Error::runtime(format!(
487 "{fn_name}: {param_name} must be a non-negative integer: {}",
488 lua_value_type_name(&other)
489 ))),
490 }
491}
492
493fn require_table_arg(value: LuaValue, fn_name: &str, param_name: &str) -> mlua::Result<Table> {
496 match value {
497 LuaValue::Table(table) => Ok(table),
498 other => Err(mlua::Error::runtime(format!(
499 "{fn_name}: {param_name} must be a table, got {}",
500 lua_value_type_name(&other)
501 ))),
502 }
503}
504
505enum ExecMode {
508 Shell { command: String },
509 Program { program: String, args: Vec<String> },
510}
511
512struct ExecRequest {
515 mode: ExecMode,
516 cwd: Option<String>,
517 env: HashMap<String, String>,
518 stdin: Option<String>,
519 timeout_ms: Option<u64>,
520}
521
522struct ExecResult {
525 ok: bool,
526 success: bool,
527 code: Option<i32>,
528 stdout: String,
529 stderr: String,
530 timed_out: bool,
531 error: Option<String>,
532}
533
534fn require_exec_scalar_text(
537 value: LuaValue,
538 fn_name: &str,
539 param_name: &str,
540 allow_blank: bool,
541) -> mlua::Result<String> {
542 match value {
543 LuaValue::String(_) => require_string_arg(value, fn_name, param_name, allow_blank),
544 LuaValue::Integer(number) => Ok(number.to_string()),
545 LuaValue::Number(number) => {
546 if !number.is_finite() {
547 return Err(mlua::Error::runtime(format!(
548 "{fn_name}: {param_name} must be a finite number"
549 )));
550 }
551 Ok(number.to_string())
552 }
553 LuaValue::Boolean(flag) => Ok(flag.to_string()),
554 other => Err(mlua::Error::runtime(format!(
555 "{fn_name}: {param_name} must be a string: {}",
556 lua_value_type_name(&other)
557 ))),
558 }
559}
560
561fn table_get_optional_string_field(
564 table: &Table,
565 fn_name: &str,
566 field_name: &str,
567 allow_blank: bool,
568) -> mlua::Result<Option<String>> {
569 let value: LuaValue = table.get(field_name)?;
570 match value {
571 LuaValue::Nil => Ok(None),
572 other => Ok(Some(require_string_arg(
573 other,
574 fn_name,
575 field_name,
576 allow_blank,
577 )?)),
578 }
579}
580
581fn table_get_optional_bool_field(
584 table: &Table,
585 fn_name: &str,
586 field_name: &str,
587) -> mlua::Result<Option<bool>> {
588 let value: LuaValue = table.get(field_name)?;
589 match value {
590 LuaValue::Nil => Ok(None),
591 LuaValue::Boolean(flag) => Ok(Some(flag)),
592 other => Err(mlua::Error::runtime(format!(
593 "{fn_name}: {field_name} must be a boolean when provided: {}",
594 lua_value_type_name(&other)
595 ))),
596 }
597}
598
599fn table_get_optional_timeout_field(
602 table: &Table,
603 fn_name: &str,
604 field_name: &str,
605) -> mlua::Result<Option<u64>> {
606 let value: LuaValue = table.get(field_name)?;
607 match value {
608 LuaValue::Nil => Ok(None),
609 LuaValue::Integer(number) if number > 0 => Ok(Some(number as u64)),
610 LuaValue::Number(number) if number.is_finite() && number.fract() == 0.0 && number > 0.0 => {
611 Ok(Some(number as u64))
612 }
613 other => Err(mlua::Error::runtime(format!(
614 "{fn_name}: {field_name} must be a positive integer in milliseconds: {}",
615 lua_value_type_name(&other)
616 ))),
617 }
618}
619
620fn table_get_string_list_field(
623 table: &Table,
624 fn_name: &str,
625 field_name: &str,
626) -> mlua::Result<Vec<String>> {
627 let value: LuaValue = table.get(field_name)?;
628 match value {
629 LuaValue::Nil => Ok(Vec::new()),
630 other => {
631 let list = require_table_arg(other, fn_name, field_name)?;
632 let mut items = Vec::new();
633 for (index, item) in list.sequence_values::<LuaValue>().enumerate() {
634 let item = item.map_err(|error| {
635 mlua::Error::runtime(format!(
636 "{fn_name}: failed to read {field_name}[{}]: {}, {}",
637 index + 1,
638 index + 1,
639 error
640 ))
641 })?;
642 items.push(require_exec_scalar_text(
643 item,
644 fn_name,
645 &format!("{field_name}[{}]", index + 1),
646 true,
647 )?);
648 }
649 Ok(items)
650 }
651 }
652}
653
654fn table_get_string_map_field(
657 table: &Table,
658 fn_name: &str,
659 field_name: &str,
660) -> mlua::Result<HashMap<String, String>> {
661 let value: LuaValue = table.get(field_name)?;
662 match value {
663 LuaValue::Nil => Ok(HashMap::new()),
664 other => {
665 let map_table = require_table_arg(other, fn_name, field_name)?;
666 let mut items = HashMap::new();
667 for pair in map_table.pairs::<LuaValue, LuaValue>() {
668 let (key_value, field_value) = pair.map_err(|_error| {
669 mlua::Error::runtime(format!("{fn_name}: failed to read {field_name}"))
670 })?;
671 let key =
672 require_string_arg(key_value, fn_name, &format!("{field_name}.<key>"), false)?;
673 let value_text = require_exec_scalar_text(
674 field_value,
675 fn_name,
676 &format!("{field_name}.{key}"),
677 true,
678 )?;
679 items.insert(key, value_text);
680 }
681 Ok(items)
682 }
683 }
684}
685
686fn parse_exec_request(value: LuaValue, fn_name: &str) -> mlua::Result<ExecRequest> {
689 match value {
690 LuaValue::String(command_text) => Ok(ExecRequest {
691 mode: ExecMode::Shell {
692 command: require_string_arg(
693 LuaValue::String(command_text),
694 fn_name,
695 "command",
696 false,
697 )?,
698 },
699 cwd: None,
700 env: HashMap::new(),
701 stdin: None,
702 timeout_ms: None,
703 }),
704 LuaValue::Table(spec) => {
705 let command = table_get_optional_string_field(&spec, fn_name, "command", false)?;
706 let program = table_get_optional_string_field(&spec, fn_name, "program", false)?;
707 let args = table_get_string_list_field(&spec, fn_name, "args")?;
708 let cwd = table_get_optional_string_field(&spec, fn_name, "cwd", false)?;
709 let env = table_get_string_map_field(&spec, fn_name, "env")?;
710 let stdin = table_get_optional_string_field(&spec, fn_name, "stdin", true)?;
711 let timeout_ms = table_get_optional_timeout_field(&spec, fn_name, "timeout_ms")?;
712 let shell_override = table_get_optional_bool_field(&spec, fn_name, "shell")?;
713
714 if let Some(current_dir) = cwd.as_deref() {
715 validate_path_text(current_dir, fn_name, "cwd")?;
716 }
717
718 let mode = match (command, program) {
719 (Some(command_text), None) => {
720 if matches!(shell_override, Some(false)) {
721 return Err(mlua::Error::runtime(format!(
722 "{fn_name}: shell=false cannot be used with command mode"
723 )));
724 }
725 if !args.is_empty() {
726 return Err(mlua::Error::runtime(format!(
727 "{fn_name}: args is only supported with program mode"
728 )));
729 }
730 ExecMode::Shell {
731 command: command_text,
732 }
733 }
734 (None, Some(program_path)) => {
735 if matches!(shell_override, Some(true)) {
736 return Err(mlua::Error::runtime(format!(
737 "{fn_name}: shell=true requires command mode"
738 )));
739 }
740 ExecMode::Program {
741 program: program_path,
742 args,
743 }
744 }
745 (Some(_), Some(_)) => {
746 return Err(mlua::Error::runtime(format!(
747 "{fn_name}: command and program are mutually exclusive"
748 )));
749 }
750 (None, None) => {
751 return Err(mlua::Error::runtime(format!(
752 "{fn_name}: expected a string command or a table with command"
753 )));
754 }
755 };
756
757 Ok(ExecRequest {
758 mode,
759 cwd,
760 env,
761 stdin,
762 timeout_ms,
763 })
764 }
765 other => Err(mlua::Error::runtime(format!(
766 "{fn_name}: expected a string or table, got {}",
767 lua_value_type_name(&other)
768 ))),
769 }
770}
771
772#[cfg(windows)]
775fn default_shell_launcher() -> (&'static str, &'static str) {
776 ("cmd.exe", "/C")
777}
778
779#[cfg(not(windows))]
782fn default_shell_launcher() -> (&'static str, &'static str) {
783 ("sh", "-c")
784}
785
786fn spawn_pipe_reader<R>(mut reader: R) -> thread::JoinHandle<String>
789where
790 R: Read + Send + 'static,
791{
792 thread::spawn(move || {
793 let mut buffer = Vec::new();
794 let _ = reader.read_to_end(&mut buffer);
795 String::from_utf8_lossy(&buffer).to_string()
796 })
797}
798
799fn spawn_stdin_writer<W>(mut writer: W, input: String) -> thread::JoinHandle<()>
802where
803 W: Write + Send + 'static,
804{
805 thread::spawn(move || {
806 let _ = writer.write_all(input.as_bytes());
807 let _ = writer.flush();
808 })
809}
810
811fn execute_exec_request(request: ExecRequest) -> ExecResult {
814 let mut command = match &request.mode {
815 ExecMode::Shell { command } => {
816 let (shell_program, shell_flag) = default_shell_launcher();
817 let mut process = Command::new(shell_program);
818 process.arg(shell_flag).arg(command);
819 process
820 }
821 ExecMode::Program { program, args } => {
822 let mut process = Command::new(program);
823 process.args(args);
824 process
825 }
826 };
827
828 if let Some(current_dir) = &request.cwd {
829 command.current_dir(current_dir);
830 }
831 if !request.env.is_empty() {
832 command.envs(&request.env);
833 }
834 command.stdout(Stdio::piped());
835 command.stderr(Stdio::piped());
836 command.stdin(if request.stdin.is_some() {
837 Stdio::piped()
838 } else {
839 Stdio::null()
840 });
841
842 let mut child = match command.spawn() {
843 Ok(child) => child,
844 Err(error) => {
845 let error_text = format!("failed to spawn process: {}", error);
846 return ExecResult {
847 ok: false,
848 success: false,
849 code: None,
850 stdout: String::new(),
851 stderr: error_text.clone(),
852 timed_out: false,
853 error: Some(error_text),
854 };
855 }
856 };
857
858 let stdout_handle = child.stdout.take().map(spawn_pipe_reader);
859 let stderr_handle = child.stderr.take().map(spawn_pipe_reader);
860 let stdin_handle = match (request.stdin.clone(), child.stdin.take()) {
861 (Some(input), Some(stdin)) => Some(spawn_stdin_writer(stdin, input)),
862 _ => None,
863 };
864
865 let mut timed_out = false;
866 let timeout = request.timeout_ms.map(Duration::from_millis);
867 let started_at = Instant::now();
868
869 let final_status = loop {
870 match child.try_wait() {
871 Ok(Some(status)) => {
872 break Some(status);
873 }
874 Ok(None) => {
875 if let Some(limit) = timeout {
876 if started_at.elapsed() >= limit {
877 timed_out = true;
878 let _ = child.kill();
879 break child.wait().ok();
880 }
881 }
882 thread::sleep(Duration::from_millis(10));
883 }
884 Err(error) => {
885 let error_text = format!("failed to wait for process: {}", error);
886 return ExecResult {
887 ok: false,
888 success: false,
889 code: None,
890 stdout: String::new(),
891 stderr: error_text.clone(),
892 timed_out,
893 error: Some(error_text),
894 };
895 }
896 }
897 };
898
899 if let Some(handle) = stdin_handle {
900 let _ = handle.join();
901 }
902
903 let stdout = stdout_handle
904 .map(|handle| handle.join().unwrap_or_default())
905 .unwrap_or_default();
906 let mut stderr = stderr_handle
907 .map(|handle| handle.join().unwrap_or_default())
908 .unwrap_or_default();
909
910 let status = match final_status {
911 Some(status) => status,
912 None => {
913 let error_text = "process finished without status".to_string();
914 return ExecResult {
915 ok: false,
916 success: false,
917 code: None,
918 stdout,
919 stderr: error_text.clone(),
920 timed_out,
921 error: Some(error_text),
922 };
923 }
924 };
925
926 let code = status.code();
927 let success = !timed_out && status.success();
928 let mut error = None;
929
930 if timed_out {
931 let timeout_value = request.timeout_ms.unwrap_or_default();
932 let timeout_text = format!("process execution timed out after {} ms", timeout_value);
933 if !stderr.is_empty() {
934 stderr.push('\n');
935 }
936 stderr.push_str(&timeout_text);
937 error = Some(timeout_text);
938 } else if !success {
939 error = Some(match code {
940 Some(exit_code) => format!("process exited with code {}", exit_code),
941 None => "process terminated without an exit code".to_string(),
942 });
943 }
944
945 ExecResult {
946 ok: success,
947 success,
948 code,
949 stdout,
950 stderr,
951 timed_out,
952 error,
953 }
954}
955
956fn exec_result_to_lua_table(lua: &Lua, result: ExecResult) -> mlua::Result<Table> {
959 let table = lua.create_table()?;
960 table.set("ok", result.ok)?;
961 table.set("success", result.success)?;
962 table.set("stdout", result.stdout)?;
963 table.set("stderr", result.stderr)?;
964 table.set("timed_out", result.timed_out)?;
965 match result.code {
966 Some(code) => table.set("code", code)?,
967 None => table.set("code", LuaValue::Nil)?,
968 }
969 match result.error {
970 Some(error_text) => table.set("error", error_text)?,
971 None => table.set("error", LuaValue::Nil)?,
972 }
973 Ok(table)
974}
975
976fn validate_skill_relative_path(
979 relative_path: &str,
980 expected_prefix: &str,
981 field_label: &str,
982) -> Result<(), String> {
983 let trimmed = relative_path.trim();
984 if trimmed.is_empty() {
985 return Err(format!("{field_label} must not be empty"));
986 }
987
988 let path = Path::new(trimmed);
989 if path.is_absolute() {
990 return Err(format!(
991 "{field_label} must be a relative path under {expected_prefix}"
992 ));
993 }
994
995 let normalized = trimmed.replace('\\', "/");
996 let required_prefix = format!("{expected_prefix}/");
997 if !normalized.starts_with(&required_prefix) {
998 return Err(format!("{field_label} must start with {required_prefix}"));
999 }
1000
1001 for component in path.components() {
1002 if !matches!(component, std::path::Component::Normal(_)) {
1003 return Err(format!("{field_label} must not contain parent"));
1004 }
1005 }
1006
1007 Ok(())
1008}
1009
1010fn tool_entry_path(skill_dir: &Path, tool: &crate::lua_skill::SkillToolMeta) -> PathBuf {
1015 skill_dir.join(&tool.lua_entry)
1016}
1017
1018#[derive(Debug, Clone, Default)]
1021struct VulcanInternalExecutionContext {
1022 tool_name: Option<String>,
1025 skill_name: Option<String>,
1028 luaexec_active: bool,
1031 luaexec_caller_tool_name: Option<String>,
1034}
1035
1036fn capture_vulcan_file_context(
1039 lua: &Lua,
1040) -> Result<(Option<String>, Option<String>, Option<String>), String> {
1041 let context = get_vulcan_context_table(lua)?;
1042 let skill_dir: Option<String> = context
1043 .get("skill_dir")
1044 .map_err(|error| format!("Failed to read vulcan.context.skill_dir: {}", error))?;
1045 let entry_dir: Option<String> = context
1046 .get("entry_dir")
1047 .map_err(|error| format!("Failed to read vulcan.context.entry_dir: {}", error))?;
1048 let entry_file: Option<String> = context
1049 .get("entry_file")
1050 .map_err(|error| format!("Failed to read vulcan.context.entry_file: {}", error))?;
1051 Ok((skill_dir, entry_dir, entry_file))
1052}
1053
1054fn populate_vulcan_file_context(
1057 lua: &Lua,
1058 skill_dir: Option<&Path>,
1059 entry_file: Option<&Path>,
1060) -> Result<(), String> {
1061 let context = get_vulcan_context_table(lua)?;
1062
1063 match skill_dir {
1064 Some(path) => context
1065 .set("skill_dir", render_host_visible_path(path))
1066 .map_err(|error| format!("Failed to set vulcan.context.skill_dir: {}", error))?,
1067 None => context
1068 .set("skill_dir", LuaValue::Nil)
1069 .map_err(|error| format!("Failed to clear vulcan.context.skill_dir: {}", error))?,
1070 }
1071
1072 match entry_file {
1073 Some(path) => {
1074 let entry_dir = path.parent().unwrap_or(path);
1075 context
1076 .set("entry_dir", render_host_visible_path(entry_dir))
1077 .map_err(|error| format!("Failed to set vulcan.context.entry_dir: {}", error))?;
1078 context
1079 .set("entry_file", render_host_visible_path(path))
1080 .map_err(|error| format!("Failed to set vulcan.context.entry_file: {}", error))?;
1081 }
1082 None => {
1083 context
1084 .set("entry_dir", LuaValue::Nil)
1085 .map_err(|error| format!("Failed to clear vulcan.context.entry_dir: {}", error))?;
1086 context
1087 .set("entry_file", LuaValue::Nil)
1088 .map_err(|error| format!("Failed to clear vulcan.context.entry_file: {}", error))?;
1089 }
1090 }
1091
1092 Ok(())
1093}
1094
1095fn populate_vulcan_dependency_context(
1098 lua: &Lua,
1099 host_options: &LuaRuntimeHostOptions,
1100 skill_dir: Option<&Path>,
1101 skill_id: Option<&str>,
1102) -> Result<(), String> {
1103 let deps = get_vulcan_deps_table(lua)?;
1104
1105 let clear_paths = || -> Result<(), String> {
1106 deps.set("tools_path", LuaValue::Nil)
1107 .map_err(|error| format!("Failed to clear vulcan.deps.tools_path: {}", error))?;
1108 deps.set("lua_path", LuaValue::Nil)
1109 .map_err(|error| format!("Failed to clear vulcan.deps.lua_path: {}", error))?;
1110 deps.set("ffi_path", LuaValue::Nil)
1111 .map_err(|error| format!("Failed to clear vulcan.deps.ffi_path: {}", error))?;
1112 Ok(())
1113 };
1114
1115 let Some(skill_dir) = skill_dir else {
1116 return clear_paths();
1117 };
1118 let Some(skill_id) = skill_id.filter(|value| !value.trim().is_empty()) else {
1119 return clear_paths();
1120 };
1121
1122 let skills_root = skill_dir.parent().ok_or_else(|| {
1123 format!(
1124 "Failed to derive skills root from skill directory {}",
1125 skill_dir.display()
1126 )
1127 })?;
1128 let runtime_root = skills_root.parent().ok_or_else(|| {
1129 format!(
1130 "Failed to derive runtime root from skill directory {}",
1131 skill_dir.display()
1132 )
1133 })?;
1134 let dependency_root = runtime_root.join(host_options.dependency_dir_name.as_str());
1135
1136 deps.set(
1137 "tools_path",
1138 render_host_visible_path(&dependency_root.join("tools").join(skill_id)),
1139 )
1140 .map_err(|error| format!("Failed to set vulcan.deps.tools_path: {}", error))?;
1141 deps.set(
1142 "lua_path",
1143 render_host_visible_path(&dependency_root.join("lua").join(skill_id)),
1144 )
1145 .map_err(|error| format!("Failed to set vulcan.deps.lua_path: {}", error))?;
1146 deps.set(
1147 "ffi_path",
1148 render_host_visible_path(&dependency_root.join("ffi").join(skill_id)),
1149 )
1150 .map_err(|error| format!("Failed to set vulcan.deps.ffi_path: {}", error))?;
1151 Ok(())
1152}
1153
1154fn capture_vulcan_internal_execution_context(
1157 lua: &Lua,
1158) -> Result<VulcanInternalExecutionContext, String> {
1159 let internal = get_vulcan_runtime_internal_table(lua)?;
1160 let tool_name: Option<String> = internal.get("tool_name").map_err(|error| {
1161 format!(
1162 "Failed to read vulcan.runtime.internal.tool_name: {}",
1163 error
1164 )
1165 })?;
1166 let skill_name: Option<String> = internal.get("skill_name").map_err(|error| {
1167 format!(
1168 "Failed to read vulcan.runtime.internal.skill_name: {}",
1169 error
1170 )
1171 })?;
1172 let luaexec_active: bool = internal.get("luaexec_active").map_err(|error| {
1173 format!(
1174 "Failed to read vulcan.runtime.internal.luaexec_active: {}",
1175 error
1176 )
1177 })?;
1178 let luaexec_caller_tool_name: Option<String> =
1179 internal.get("luaexec_caller_tool_name").map_err(|error| {
1180 format!(
1181 "Failed to read vulcan.runtime.internal.luaexec_caller_tool_name: {}",
1182 error
1183 )
1184 })?;
1185 Ok(VulcanInternalExecutionContext {
1186 tool_name,
1187 skill_name,
1188 luaexec_active,
1189 luaexec_caller_tool_name,
1190 })
1191}
1192
1193fn populate_vulcan_internal_execution_context(
1196 lua: &Lua,
1197 context: &VulcanInternalExecutionContext,
1198) -> Result<(), String> {
1199 let internal = get_vulcan_runtime_internal_table(lua)?;
1200
1201 match context.tool_name.as_deref() {
1202 Some(tool_name) => internal.set("tool_name", tool_name).map_err(|error| {
1203 format!("Failed to set vulcan.runtime.internal.tool_name: {}", error)
1204 })?,
1205 None => internal.set("tool_name", LuaValue::Nil).map_err(|error| {
1206 format!(
1207 "Failed to clear vulcan.runtime.internal.tool_name: {}",
1208 error
1209 )
1210 })?,
1211 }
1212
1213 match context.skill_name.as_deref() {
1214 Some(skill_name) => internal.set("skill_name", skill_name).map_err(|error| {
1215 format!(
1216 "Failed to set vulcan.runtime.internal.skill_name: {}",
1217 error
1218 )
1219 })?,
1220 None => internal.set("skill_name", LuaValue::Nil).map_err(|error| {
1221 format!(
1222 "Failed to clear vulcan.runtime.internal.skill_name: {}",
1223 error
1224 )
1225 })?,
1226 }
1227
1228 internal
1229 .set("luaexec_active", context.luaexec_active)
1230 .map_err(|error| {
1231 format!(
1232 "Failed to set vulcan.runtime.internal.luaexec_active: {}",
1233 error
1234 )
1235 })?;
1236
1237 match context.luaexec_caller_tool_name.as_deref() {
1238 Some(tool_name) => internal
1239 .set("luaexec_caller_tool_name", tool_name)
1240 .map_err(|error| {
1241 format!(
1242 "Failed to set vulcan.runtime.internal.luaexec_caller_tool_name: {}",
1243 error
1244 )
1245 })?,
1246 None => internal
1247 .set("luaexec_caller_tool_name", LuaValue::Nil)
1248 .map_err(|error| {
1249 format!(
1250 "Failed to clear vulcan.runtime.internal.luaexec_caller_tool_name: {}",
1251 error
1252 )
1253 })?,
1254 }
1255
1256 Ok(())
1257}
1258
1259fn current_vulcan_config_skill_id(lua: &Lua, api_name: &str) -> Result<String, mlua::Error> {
1262 let internal = get_vulcan_runtime_internal_table(lua)
1263 .map_err(|error| mlua::Error::runtime(format!("{}: {}", api_name, error)))?;
1264 let skill_name: Option<String> = internal
1265 .get("skill_name")
1266 .map_err(|error| mlua::Error::runtime(format!("{}: {}", api_name, error)))?;
1267 skill_name
1268 .filter(|value| !value.trim().is_empty())
1269 .ok_or_else(|| {
1270 mlua::Error::runtime(format!("{} requires one active skill context", api_name))
1271 })
1272}
1273
1274fn is_lua_help_file(relative_path: &str) -> bool {
1277 Path::new(relative_path)
1278 .extension()
1279 .and_then(|ext| ext.to_str())
1280 .map(|ext| ext.eq_ignore_ascii_case("lua"))
1281 .unwrap_or(false)
1282}
1283
1284fn read_skill_text_file(
1287 skill_dir: &Path,
1288 relative_path: &str,
1289 label: &str,
1290) -> Result<String, String> {
1291 let file_path = skill_dir.join(relative_path);
1292 std::fs::read_to_string(&file_path).map_err(|error| {
1293 format!(
1294 "Failed to read {label} file {}: {}",
1295 file_path.display(),
1296 error
1297 )
1298 })
1299}
1300
1301fn get_vulcan_table(lua: &Lua) -> Result<Table, String> {
1304 lua.globals()
1305 .get("vulcan")
1306 .map_err(|error| format!("Failed to get vulcan module: {}", error))
1307}
1308
1309fn get_vulcan_context_table(lua: &Lua) -> Result<Table, String> {
1312 let vulcan = get_vulcan_table(lua)?;
1313 vulcan
1314 .get("context")
1315 .map_err(|error| format!("Failed to get vulcan.context: {}", error))
1316}
1317
1318fn get_vulcan_deps_table(lua: &Lua) -> Result<Table, String> {
1321 let vulcan = get_vulcan_table(lua)?;
1322 vulcan
1323 .get("deps")
1324 .map_err(|error| format!("Failed to get vulcan.deps: {}", error))
1325}
1326
1327fn get_vulcan_runtime_table(lua: &Lua) -> Result<Table, String> {
1330 let vulcan = get_vulcan_table(lua)?;
1331 vulcan
1332 .get("runtime")
1333 .map_err(|error| format!("Failed to get vulcan.runtime: {}", error))
1334}
1335
1336fn get_vulcan_runtime_internal_table(lua: &Lua) -> Result<Table, String> {
1339 let runtime = get_vulcan_runtime_table(lua)?;
1340 runtime
1341 .get("internal")
1342 .map_err(|error| format!("Failed to get vulcan.runtime.internal: {}", error))
1343}
1344
1345fn get_vulcan_runtime_lua_table(lua: &Lua) -> Result<Table, String> {
1348 let runtime = get_vulcan_runtime_table(lua)?;
1349 runtime
1350 .get("lua")
1351 .map_err(|error| format!("Failed to get vulcan.runtime.lua: {}", error))
1352}
1353
1354#[derive(Clone)]
1357struct VulcanCoreModuleState {
1358 vulcan: Table,
1359 call: Function,
1360 runtime: Table,
1361 runtime_skills: Table,
1362 runtime_internal: Table,
1363 runtime_lua: Table,
1364 fs: Table,
1365 path: Table,
1366 process: Table,
1367 os: Table,
1368 json: Table,
1369 cache: Table,
1370 context: Table,
1371 deps: Table,
1372}
1373
1374impl VulcanCoreModuleState {
1375 fn capture(lua: &Lua) -> Result<Self, String> {
1378 let vulcan = get_vulcan_table(lua)?;
1379 let runtime = get_vulcan_runtime_table(lua)?;
1380 Ok(Self {
1381 call: vulcan
1382 .get("call")
1383 .map_err(|error| format!("Failed to get vulcan.call: {}", error))?,
1384 runtime_skills: runtime
1385 .get("skills")
1386 .map_err(|error| format!("Failed to get vulcan.runtime.skills: {}", error))?,
1387 runtime_internal: runtime
1388 .get("internal")
1389 .map_err(|error| format!("Failed to get vulcan.runtime.internal: {}", error))?,
1390 runtime_lua: runtime
1391 .get("lua")
1392 .map_err(|error| format!("Failed to get vulcan.runtime.lua: {}", error))?,
1393 fs: vulcan
1394 .get("fs")
1395 .map_err(|error| format!("Failed to get vulcan.fs: {}", error))?,
1396 path: vulcan
1397 .get("path")
1398 .map_err(|error| format!("Failed to get vulcan.path: {}", error))?,
1399 process: vulcan
1400 .get("process")
1401 .map_err(|error| format!("Failed to get vulcan.process: {}", error))?,
1402 os: vulcan
1403 .get("os")
1404 .map_err(|error| format!("Failed to get vulcan.os: {}", error))?,
1405 json: vulcan
1406 .get("json")
1407 .map_err(|error| format!("Failed to get vulcan.json: {}", error))?,
1408 cache: vulcan
1409 .get("cache")
1410 .map_err(|error| format!("Failed to get vulcan.cache: {}", error))?,
1411 context: vulcan
1412 .get("context")
1413 .map_err(|error| format!("Failed to get vulcan.context: {}", error))?,
1414 deps: vulcan
1415 .get("deps")
1416 .map_err(|error| format!("Failed to get vulcan.deps: {}", error))?,
1417 vulcan,
1418 runtime,
1419 })
1420 }
1421
1422 fn restore(&self, lua: &Lua) -> Result<(), String> {
1425 self.runtime
1426 .set("skills", self.runtime_skills.clone())
1427 .map_err(|error| format!("Failed to restore vulcan.runtime.skills: {}", error))?;
1428 self.runtime
1429 .set("internal", self.runtime_internal.clone())
1430 .map_err(|error| format!("Failed to restore vulcan.runtime.internal: {}", error))?;
1431 self.runtime
1432 .set("lua", self.runtime_lua.clone())
1433 .map_err(|error| format!("Failed to restore vulcan.runtime.lua: {}", error))?;
1434 self.vulcan
1435 .set("call", self.call.clone())
1436 .map_err(|error| format!("Failed to restore vulcan.call: {}", error))?;
1437 self.vulcan
1438 .set("runtime", self.runtime.clone())
1439 .map_err(|error| format!("Failed to restore vulcan.runtime: {}", error))?;
1440 self.vulcan
1441 .set("fs", self.fs.clone())
1442 .map_err(|error| format!("Failed to restore vulcan.fs: {}", error))?;
1443 self.vulcan
1444 .set("path", self.path.clone())
1445 .map_err(|error| format!("Failed to restore vulcan.path: {}", error))?;
1446 self.vulcan
1447 .set("process", self.process.clone())
1448 .map_err(|error| format!("Failed to restore vulcan.process: {}", error))?;
1449 self.vulcan
1450 .set("os", self.os.clone())
1451 .map_err(|error| format!("Failed to restore vulcan.os: {}", error))?;
1452 self.vulcan
1453 .set("json", self.json.clone())
1454 .map_err(|error| format!("Failed to restore vulcan.json: {}", error))?;
1455 self.vulcan
1456 .set("cache", self.cache.clone())
1457 .map_err(|error| format!("Failed to restore vulcan.cache: {}", error))?;
1458 self.vulcan
1459 .set("context", self.context.clone())
1460 .map_err(|error| format!("Failed to restore vulcan.context: {}", error))?;
1461 self.vulcan
1462 .set("deps", self.deps.clone())
1463 .map_err(|error| format!("Failed to restore vulcan.deps: {}", error))?;
1464 lua.globals()
1465 .set("vulcan", self.vulcan.clone())
1466 .map_err(|error| format!("Failed to restore global vulcan module: {}", error))?;
1467 Ok(())
1468 }
1469}
1470
1471fn non_empty_skill_name(value: &str) -> Option<&str> {
1474 if value.trim().is_empty() {
1475 None
1476 } else {
1477 Some(value)
1478 }
1479}
1480
1481fn clear_runlua_args_global(lua: &Lua) -> Result<(), String> {
1484 lua.globals()
1485 .set("__runlua_args", LuaValue::Nil)
1486 .map_err(|error| format!("Failed to clear __runlua_args: {}", error))
1487}
1488
1489fn reset_pooled_vm_request_scope(
1492 lua: &Lua,
1493 host_options: &LuaRuntimeHostOptions,
1494) -> Result<(), String> {
1495 LuaEngine::populate_vulcan_request_context(lua, None)?;
1496 populate_vulcan_internal_execution_context(lua, &VulcanInternalExecutionContext::default())?;
1497 populate_vulcan_file_context(lua, None, None)?;
1498 populate_vulcan_dependency_context(lua, host_options, None, None)?;
1499 LuaEngine::populate_vulcan_lancedb_context(lua, None, None)?;
1500 LuaEngine::populate_vulcan_sqlite_context(lua, None, None)?;
1501 clear_runlua_args_global(lua)?;
1502 Ok(())
1503}
1504
1505struct LuaVmRequestScopeGuard<'a> {
1508 lease: &'a mut LuaVmLease,
1509 host_options: &'a LuaRuntimeHostOptions,
1510 active: bool,
1511}
1512
1513impl<'a> LuaVmRequestScopeGuard<'a> {
1514 fn new(
1517 lease: &'a mut LuaVmLease,
1518 host_options: &'a LuaRuntimeHostOptions,
1519 ) -> Result<Self, String> {
1520 let mut guard = Self {
1521 lease,
1522 host_options,
1523 active: true,
1524 };
1525 if let Err(error) = reset_pooled_vm_request_scope(guard.lua(), host_options) {
1526 guard.lease.discard();
1527 guard.active = false;
1528 return Err(error);
1529 }
1530 Ok(guard)
1531 }
1532
1533 fn lua(&self) -> &Lua {
1536 self.lease.lua()
1537 }
1538
1539 fn finish(mut self) -> Result<(), String> {
1542 let cleanup_result = reset_pooled_vm_request_scope(self.lua(), self.host_options);
1543 if let Err(error) = cleanup_result {
1544 self.lease.discard();
1545 self.active = false;
1546 return Err(error);
1547 }
1548 self.active = false;
1549 Ok(())
1550 }
1551}
1552
1553impl Drop for LuaVmRequestScopeGuard<'_> {
1554 fn drop(&mut self) {
1555 if !self.active {
1556 return;
1557 }
1558 if let Err(error) = reset_pooled_vm_request_scope(self.lua(), self.host_options) {
1559 log_error(format!(
1560 "[LuaSkill:error] Failed to reset pooled Lua VM request scope: {}",
1561 error
1562 ));
1563 self.lease.discard();
1564 }
1565 }
1566}
1567
1568struct LuaNestedCallScopeGuard {
1571 lua: Lua,
1572 host_options: Arc<LuaRuntimeHostOptions>,
1573 lancedb_host: Option<Arc<LanceDbSkillHost>>,
1574 sqlite_host: Option<Arc<SqliteSkillHost>>,
1575 core_state: VulcanCoreModuleState,
1576 previous_context: LuaValue,
1577 previous_client_info: LuaValue,
1578 previous_client_capabilities: LuaValue,
1579 previous_client_budget: LuaValue,
1580 previous_tool_config: LuaValue,
1581 previous_lancedb_skill_name: String,
1582 previous_sqlite_skill_name: String,
1583 previous_internal_context: VulcanInternalExecutionContext,
1584 previous_file_context: (Option<String>, Option<String>, Option<String>),
1585 active: bool,
1586}
1587
1588impl LuaNestedCallScopeGuard {
1589 fn new(
1592 lua: &Lua,
1593 host_options: Arc<LuaRuntimeHostOptions>,
1594 lancedb_host: Option<Arc<LanceDbSkillHost>>,
1595 sqlite_host: Option<Arc<SqliteSkillHost>>,
1596 ) -> Result<Self, String> {
1597 let vulcan = get_vulcan_table(lua)?;
1598 let context_table = get_vulcan_context_table(lua)?;
1599 Ok(Self {
1600 lua: lua.clone(),
1601 host_options,
1602 lancedb_host,
1603 sqlite_host,
1604 core_state: VulcanCoreModuleState::capture(lua)?,
1605 previous_context: context_table
1606 .get("request")
1607 .map_err(|error| format!("Failed to read vulcan.context.request: {}", error))?,
1608 previous_client_info: context_table
1609 .get("client_info")
1610 .map_err(|error| format!("Failed to read vulcan.context.client_info: {}", error))?,
1611 previous_client_capabilities: context_table.get("client_capabilities").map_err(
1612 |error| {
1613 format!(
1614 "Failed to read vulcan.context.client_capabilities: {}",
1615 error
1616 )
1617 },
1618 )?,
1619 previous_client_budget: context_table.get("client_budget").map_err(|error| {
1620 format!("Failed to read vulcan.context.client_budget: {}", error)
1621 })?,
1622 previous_tool_config: context_table
1623 .get("tool_config")
1624 .map_err(|error| format!("Failed to read vulcan.context.tool_config: {}", error))?,
1625 previous_lancedb_skill_name: vulcan.get("__lancedb_skill_name").unwrap_or_default(),
1626 previous_sqlite_skill_name: vulcan.get("__sqlite_skill_name").unwrap_or_default(),
1627 previous_internal_context: capture_vulcan_internal_execution_context(lua)?,
1628 previous_file_context: capture_vulcan_file_context(lua)?,
1629 active: true,
1630 })
1631 }
1632
1633 fn enter_nested_call(
1636 &self,
1637 dispatch_entry_display_name: &str,
1638 owner_skill_name: &str,
1639 owner_skill_dir: &str,
1640 entry_path: &str,
1641 nested_invocation_context: &LuaInvocationContext,
1642 target_lancedb_binding: Option<Arc<LanceDbSkillBinding>>,
1643 target_sqlite_binding: Option<Arc<SqliteSkillBinding>>,
1644 ) -> Result<(), String> {
1645 LuaEngine::populate_vulcan_request_context(&self.lua, Some(nested_invocation_context))?;
1646 populate_vulcan_internal_execution_context(
1647 &self.lua,
1648 &VulcanInternalExecutionContext {
1649 tool_name: Some(dispatch_entry_display_name.to_string()),
1650 skill_name: Some(owner_skill_name.to_string()),
1651 luaexec_active: self.previous_internal_context.luaexec_active,
1652 luaexec_caller_tool_name: self
1653 .previous_internal_context
1654 .luaexec_caller_tool_name
1655 .clone(),
1656 },
1657 )?;
1658 populate_vulcan_file_context(
1659 &self.lua,
1660 Some(Path::new(owner_skill_dir)),
1661 Some(Path::new(entry_path)),
1662 )?;
1663 populate_vulcan_dependency_context(
1664 &self.lua,
1665 self.host_options.as_ref(),
1666 Some(Path::new(owner_skill_dir)),
1667 Some(owner_skill_name),
1668 )?;
1669 LuaEngine::populate_vulcan_lancedb_context(
1670 &self.lua,
1671 target_lancedb_binding,
1672 Some(owner_skill_name),
1673 )?;
1674 LuaEngine::populate_vulcan_sqlite_context(
1675 &self.lua,
1676 target_sqlite_binding,
1677 Some(owner_skill_name),
1678 )?;
1679 Ok(())
1680 }
1681
1682 fn restore_previous_state(&self) -> Result<(), String> {
1685 self.core_state.restore(&self.lua)?;
1686 let restore_lancedb_binding = match non_empty_skill_name(&self.previous_lancedb_skill_name)
1687 {
1688 Some(skill_name) => self
1689 .lancedb_host
1690 .as_ref()
1691 .map(|host| host.binding_for_skill(skill_name))
1692 .transpose()?
1693 .flatten(),
1694 None => None,
1695 };
1696 let restore_sqlite_binding = match non_empty_skill_name(&self.previous_sqlite_skill_name) {
1697 Some(skill_name) => self
1698 .sqlite_host
1699 .as_ref()
1700 .map(|host| host.binding_for_skill(skill_name))
1701 .transpose()?
1702 .flatten(),
1703 None => None,
1704 };
1705 LuaEngine::populate_vulcan_lancedb_context(
1706 &self.lua,
1707 restore_lancedb_binding,
1708 non_empty_skill_name(&self.previous_lancedb_skill_name),
1709 )?;
1710 LuaEngine::populate_vulcan_sqlite_context(
1711 &self.lua,
1712 restore_sqlite_binding,
1713 non_empty_skill_name(&self.previous_sqlite_skill_name),
1714 )?;
1715 let context_table = get_vulcan_context_table(&self.lua)?;
1716 context_table
1717 .set("request", self.previous_context.clone())
1718 .map_err(|error| format!("Failed to restore vulcan.context.request: {}", error))?;
1719 context_table
1720 .set("client_info", self.previous_client_info.clone())
1721 .map_err(|error| format!("Failed to restore vulcan.context.client_info: {}", error))?;
1722 context_table
1723 .set(
1724 "client_capabilities",
1725 self.previous_client_capabilities.clone(),
1726 )
1727 .map_err(|error| {
1728 format!(
1729 "Failed to restore vulcan.context.client_capabilities: {}",
1730 error
1731 )
1732 })?;
1733 context_table
1734 .set("client_budget", self.previous_client_budget.clone())
1735 .map_err(|error| {
1736 format!("Failed to restore vulcan.context.client_budget: {}", error)
1737 })?;
1738 context_table
1739 .set("tool_config", self.previous_tool_config.clone())
1740 .map_err(|error| format!("Failed to restore vulcan.context.tool_config: {}", error))?;
1741 populate_vulcan_internal_execution_context(&self.lua, &self.previous_internal_context)?;
1742 populate_vulcan_file_context(
1743 &self.lua,
1744 self.previous_file_context.0.as_deref().map(Path::new),
1745 self.previous_file_context.2.as_deref().map(Path::new),
1746 )?;
1747 populate_vulcan_dependency_context(
1748 &self.lua,
1749 self.host_options.as_ref(),
1750 self.previous_file_context.0.as_deref().map(Path::new),
1751 self.previous_internal_context.skill_name.as_deref(),
1752 )?;
1753 Ok(())
1754 }
1755
1756 fn finish(mut self) -> Result<(), String> {
1759 let restore_result = self.restore_previous_state();
1760 self.active = false;
1761 restore_result
1762 }
1763}
1764
1765impl Drop for LuaNestedCallScopeGuard {
1766 fn drop(&mut self) {
1767 if !self.active {
1768 return;
1769 }
1770 if let Err(error) = self.restore_previous_state() {
1771 log_error(format!(
1772 "[LuaSkill:error] Failed to restore nested vulcan.call context: {}",
1773 error
1774 ));
1775 }
1776 }
1777}
1778
1779struct LuaVmLease {
1782 pool: Arc<LuaVmPool>,
1783 vm: Option<LuaVm>,
1784}
1785
1786impl LuaVmLease {
1787 fn lua(&self) -> &Lua {
1790 &self.vm.as_ref().expect("lua vm lease missing instance").lua
1791 }
1792
1793 fn discard(&mut self) {
1796 if let Some(vm) = self.vm.take() {
1797 self.pool.discard(vm);
1798 }
1799 }
1800}
1801
1802impl Drop for LuaVmLease {
1803 fn drop(&mut self) {
1804 if let Some(mut vm) = self.vm.take() {
1805 vm.last_used_at = Instant::now();
1806 self.pool.release(vm);
1807 }
1808 }
1809}
1810
1811impl LuaVmPool {
1812 fn new(config: LuaVmPoolConfig) -> Self {
1815 Self {
1816 config: config.normalized(),
1817 state: Mutex::new(LuaVmPoolState {
1818 available: Vec::new(),
1819 total_count: 0,
1820 }),
1821 condvar: Condvar::new(),
1822 }
1823 }
1824
1825 fn prewarm<F>(&self, mut factory: F) -> Result<(), String>
1828 where
1829 F: FnMut() -> Result<LuaVm, String>,
1830 {
1831 while self.total_count() < self.config.min_size {
1832 {
1833 let mut state = self.state.lock().unwrap();
1834 state.total_count += 1;
1835 }
1836 match factory() {
1837 Ok(vm) => self.release(vm),
1838 Err(error) => {
1839 let mut state = self.state.lock().unwrap();
1840 state.total_count = state.total_count.saturating_sub(1);
1841 return Err(error);
1842 }
1843 }
1844 }
1845 Ok(())
1846 }
1847
1848 fn acquire<F>(self: &Arc<Self>, mut factory: F) -> Result<LuaVmLease, String>
1851 where
1852 F: FnMut() -> Result<LuaVm, String>,
1853 {
1854 loop {
1855 let mut state = self.state.lock().unwrap();
1856 self.reap_idle_locked(&mut state);
1857
1858 if let Some(mut vm) = state.available.pop() {
1859 vm.last_used_at = Instant::now();
1860 return Ok(LuaVmLease {
1861 pool: self.clone(),
1862 vm: Some(vm),
1863 });
1864 }
1865
1866 if state.total_count < self.config.max_size {
1867 state.total_count += 1;
1868 drop(state);
1869 match factory() {
1870 Ok(vm) => {
1871 return Ok(LuaVmLease {
1872 pool: self.clone(),
1873 vm: Some(vm),
1874 });
1875 }
1876 Err(error) => {
1877 let mut state = self.state.lock().unwrap();
1878 state.total_count = state.total_count.saturating_sub(1);
1879 self.condvar.notify_one();
1880 return Err(error);
1881 }
1882 }
1883 }
1884
1885 let _guard = self.condvar.wait(state).unwrap();
1886 }
1887 }
1888
1889 fn release(&self, vm: LuaVm) {
1892 let mut state = self.state.lock().unwrap();
1893 state.available.push(vm);
1894 self.reap_idle_locked(&mut state);
1895 self.condvar.notify_one();
1896 }
1897
1898 fn discard(&self, _vm: LuaVm) {
1901 let mut state = self.state.lock().unwrap();
1902 if state.total_count > 0 {
1903 state.total_count -= 1;
1904 }
1905 self.condvar.notify_one();
1906 }
1907
1908 fn total_count(&self) -> usize {
1911 self.state.lock().unwrap().total_count
1912 }
1913
1914 fn reap_idle_locked(&self, state: &mut LuaVmPoolState) {
1917 if state.total_count <= self.config.min_size {
1918 return;
1919 }
1920
1921 let idle_limit = Duration::from_secs(self.config.idle_ttl_secs);
1922 let now = Instant::now();
1923 let mut index = 0usize;
1924 while index < state.available.len() && state.total_count > self.config.min_size {
1925 let should_remove = now
1926 .checked_duration_since(state.available[index].last_used_at)
1927 .map(|idle| idle >= idle_limit)
1928 .unwrap_or(false);
1929 if should_remove {
1930 state.available.swap_remove(index);
1931 state.total_count = state.total_count.saturating_sub(1);
1932 } else {
1933 index += 1;
1934 }
1935 }
1936 }
1937}
1938
1939fn parse_tool_call_output(
1942 values: MultiValue,
1943 display_name: &str,
1944) -> Result<RuntimeInvocationResult, String> {
1945 let values_vec: Vec<LuaValue> = values.into_vec();
1946 if values_vec.is_empty() {
1947 return Err(format!(
1948 "Lua skill '{}' must return a plain string result",
1949 display_name
1950 ));
1951 }
1952
1953 if values_vec.len() > 3 {
1954 return Err(format!(
1955 "Lua skill '{}' must return content[, overflow_mode[, template_hint]]",
1956 display_name
1957 ));
1958 }
1959
1960 let content = match &values_vec[0] {
1961 LuaValue::String(text) => text
1962 .to_str()
1963 .map_err(|error| {
1964 format!(
1965 "Lua skill '{}' returned an invalid UTF-8 string: {}",
1966 display_name, error
1967 )
1968 })?
1969 .to_string(),
1970 other => {
1971 return Err(format!(
1972 "{} (skill='{}', actual_type='{}')",
1973 NON_STRING_TOOL_RESULT_ERROR,
1974 display_name,
1975 lua_value_type_name(other)
1976 ));
1977 }
1978 };
1979
1980 let overflow_mode = match values_vec.get(1) {
1981 None | Some(LuaValue::Nil) => None,
1982 Some(LuaValue::String(text)) => {
1983 let mode_text = text.to_str().map_err(|error| {
1984 format!(
1985 "Lua skill '{}' returned an invalid overflow mode string: {}",
1986 display_name, error
1987 )
1988 })?;
1989 Some(ToolOverflowMode::parse(&mode_text).ok_or_else(|| {
1990 format!(
1991 "Lua skill '{}' returned an unsupported overflow mode: {}",
1992 display_name, mode_text
1993 )
1994 })?)
1995 }
1996 Some(_) => {
1997 return Err(format!(
1998 "Lua skill '{}' must return overflow mode as a string constant",
1999 display_name
2000 ));
2001 }
2002 };
2003
2004 let template_hint = match values_vec.get(2) {
2005 None | Some(LuaValue::Nil) => None,
2006 Some(LuaValue::String(text)) => {
2007 let name = text.to_str().map_err(|error| {
2008 format!(
2009 "Lua skill '{}' returned an invalid template name: {}",
2010 display_name, error
2011 )
2012 })?;
2013 let trimmed = name.trim();
2014 if trimmed.is_empty() {
2015 None
2016 } else {
2017 Some(trimmed.to_string())
2018 }
2019 }
2020 Some(_) => {
2021 return Err(format!(
2022 "Lua skill '{}' must return template_hint as a string",
2023 display_name
2024 ));
2025 }
2026 };
2027
2028 Ok(RuntimeInvocationResult::from_content_parts(
2029 content,
2030 overflow_mode,
2031 template_hint,
2032 ))
2033}
2034
2035impl LuaEngine {
2036 fn reference_skill_root<'a>(
2039 &self,
2040 skill_roots: &'a [RuntimeSkillRoot],
2041 ) -> Result<&'a RuntimeSkillRoot, String> {
2042 skill_roots
2043 .iter()
2044 .rev()
2045 .find(|root| root.skills_dir.exists() && root.skills_dir.is_dir())
2046 .ok_or_else(|| "at least one skill root is required".to_string())
2047 }
2048
2049 fn canonical_skill_config_runtime_root(
2052 &self,
2053 skill_roots: &[RuntimeSkillRoot],
2054 ) -> Result<PathBuf, String> {
2055 let mut candidates: Vec<PathBuf> = Vec::new();
2056 for skill_root in skill_roots {
2057 let candidate = normalize_runtime_root_path(&self.runtime_root_for(skill_root));
2058 if !candidates.iter().any(|existing| existing == &candidate) {
2059 candidates.push(candidate);
2060 }
2061 }
2062 match candidates.len() {
2063 0 => Err("at least one skill root is required to resolve the unified skill config path".to_string()),
2064 1 => Ok(candidates.remove(0)),
2065 _ => Err(
2066 "multiple runtime roots map to different parents; set host_options.skill_config_file_path explicitly".to_string()
2067 ),
2068 }
2069 }
2070
2071 pub fn new(options: LuaEngineOptions) -> Result<Self, Box<dyn std::error::Error>> {
2073 let runlua_pool_config = options
2074 .host_options
2075 .runlua_pool_config
2076 .map(|config| LuaVmPoolConfig {
2077 min_size: config.min_size,
2078 max_size: config.max_size,
2079 idle_ttl_secs: config.idle_ttl_secs,
2080 })
2081 .unwrap_or_else(default_runlua_vm_pool_config);
2082 configure_global_tool_cache(
2083 options
2084 .host_options
2085 .cache_config
2086 .clone()
2087 .unwrap_or_else(ToolCacheConfig::default),
2088 );
2089 let database_provider_callbacks = Arc::new(
2090 RuntimeDatabaseProviderCallbacks::capture_process_defaults()
2091 .map_err(std::io::Error::other)?,
2092 );
2093 Ok(Self {
2094 skills: HashMap::new(),
2095 entry_registry: BTreeMap::new(),
2096 pool: Arc::new(LuaVmPool::new(options.pool_config)),
2097 runlua_pool: Arc::new(LuaVmPool::new(runlua_pool_config)),
2098 skill_config_store: Arc::new(
2099 SkillConfigStore::new(options.host_options.skill_config_file_path.clone())
2100 .map_err(std::io::Error::other)?,
2101 ),
2102 lancedb_host: None,
2103 sqlite_host: None,
2104 database_provider_callbacks,
2105 host_options: Arc::new(options.host_options),
2106 })
2107 }
2108
2109 fn runtime_root_for(&self, skill_root: &RuntimeSkillRoot) -> PathBuf {
2112 skill_root
2113 .skills_dir
2114 .parent()
2115 .map(Path::to_path_buf)
2116 .unwrap_or_else(|| skill_root.skills_dir.clone())
2117 }
2118
2119 fn state_root_for(&self, skill_root: &RuntimeSkillRoot) -> PathBuf {
2122 self.runtime_root_for(skill_root)
2123 .join(self.host_options.state_dir_name.as_str())
2124 }
2125
2126 fn dependency_root_for(&self, skill_root: &RuntimeSkillRoot) -> PathBuf {
2129 self.runtime_root_for(skill_root)
2130 .join(self.host_options.dependency_dir_name.as_str())
2131 }
2132
2133 fn is_host_ignored_skill(&self, skill_id: &str) -> bool {
2136 self.host_options
2137 .ignored_skill_ids
2138 .iter()
2139 .any(|ignored| ignored.trim() == skill_id)
2140 }
2141
2142 fn database_root_for(&self, skill_root: &RuntimeSkillRoot) -> PathBuf {
2145 self.runtime_root_for(skill_root)
2146 .join(self.host_options.database_dir_name.as_str())
2147 }
2148
2149 fn refresh_skill_config_runtime_root(
2152 &self,
2153 skill_roots: &[RuntimeSkillRoot],
2154 ) -> Result<(), String> {
2155 if self.skill_config_store.has_explicit_file_path() {
2156 return Ok(());
2157 }
2158 let runtime_root = self.canonical_skill_config_runtime_root(skill_roots)?;
2159 self.skill_config_store
2160 .set_default_runtime_root(&runtime_root)
2161 }
2162
2163 fn dependency_manager_config_for(
2166 &self,
2167 skill_root: &RuntimeSkillRoot,
2168 ) -> Result<DependencyManagerConfig, String> {
2169 let runtime_root = skill_root
2170 .skills_dir
2171 .parent()
2172 .map(Path::to_path_buf)
2173 .unwrap_or_else(|| skill_root.skills_dir.clone());
2174 let dependency_root = self.dependency_root_for(skill_root);
2175 let tool_root = dependency_root.join("tools");
2176 let host_tool_root = self
2177 .host_options
2178 .host_provided_tool_root
2179 .clone()
2180 .unwrap_or_else(|| runtime_root.join("bin").join("tools"));
2181 let lua_root = dependency_root.join("lua");
2182 let host_lua_root = self
2183 .host_options
2184 .host_provided_lua_root
2185 .clone()
2186 .or_else(|| self.host_options.lua_packages_dir.clone())
2187 .unwrap_or_else(|| runtime_root.join("lua_packages"));
2188 let ffi_root = dependency_root.join("ffi");
2189 let host_ffi_root = self
2190 .host_options
2191 .host_provided_ffi_root
2192 .clone()
2193 .or_else(|| {
2194 self.host_options
2195 .lancedb_library_path
2196 .as_ref()
2197 .and_then(|path| path.parent().map(Path::to_path_buf))
2198 })
2199 .or_else(|| {
2200 self.host_options
2201 .sqlite_library_path
2202 .as_ref()
2203 .and_then(|path| path.parent().map(Path::to_path_buf))
2204 })
2205 .unwrap_or_else(|| runtime_root.join("libs"));
2206 let download_cache_root = self
2207 .host_options
2208 .download_cache_root
2209 .clone()
2210 .unwrap_or_else(|| runtime_root.join("temp").join("downloads"));
2211
2212 ensure_directory(&tool_root)?;
2213 ensure_directory(&host_tool_root)?;
2214 ensure_directory(&lua_root)?;
2215 ensure_directory(&host_lua_root)?;
2216 ensure_directory(&ffi_root)?;
2217 ensure_directory(&host_ffi_root)?;
2218 ensure_directory(&download_cache_root)?;
2219
2220 Ok(DependencyManagerConfig {
2221 tool_root,
2222 host_tool_root,
2223 lua_root,
2224 host_lua_root,
2225 ffi_root,
2226 host_ffi_root,
2227 download_cache_root,
2228 allow_network_download: self.host_options.allow_network_download,
2229 github_base_url: self.host_options.github_base_url.clone(),
2230 github_api_base_url: self.host_options.github_api_base_url.clone(),
2231 })
2232 }
2233
2234 fn skill_manager_for(&self, skill_root: &RuntimeSkillRoot) -> Result<SkillManager, String> {
2237 let state_root = self.state_root_for(skill_root);
2238 let dependency_config = self.dependency_manager_config_for(skill_root)?;
2239 ensure_directory(&state_root)?;
2240 Ok(SkillManager::new(SkillManagerConfig {
2241 skill_root: skill_root.clone(),
2242 lifecycle_root: state_root,
2243 protection: self.host_options.protection.clone(),
2244 download_cache_root: dependency_config.download_cache_root,
2245 allow_network_download: dependency_config.allow_network_download,
2246 github_base_url: dependency_config.github_base_url,
2247 github_api_base_url: dependency_config.github_api_base_url,
2248 }))
2249 }
2250
2251 fn ensure_skill_dependencies(
2254 &self,
2255 skill_root: &RuntimeSkillRoot,
2256 skill_dir: &Path,
2257 ) -> Result<(), String> {
2258 let dependencies_path = skill_dir.join("dependencies.yaml");
2259 if !dependencies_path.exists() {
2260 return Ok(());
2261 }
2262
2263 let manifest = SkillDependencyManifest::load_from_path(&dependencies_path)?;
2264 if manifest.is_empty() {
2265 return Ok(());
2266 }
2267
2268 let skill_name = skill_dir
2269 .file_name()
2270 .and_then(|value| value.to_str())
2271 .unwrap_or("unknown-skill");
2272 let manager = DependencyManager::new(self.dependency_manager_config_for(skill_root)?);
2273 manager.ensure_skill_dependencies(skill_name, &manifest)
2274 }
2275
2276 fn load_skill_dependency_manifest(
2279 &self,
2280 skill_dir: &Path,
2281 ) -> Result<Option<SkillDependencyManifest>, String> {
2282 let dependencies_path = skill_dir.join("dependencies.yaml");
2283 if !dependencies_path.exists() {
2284 return Ok(None);
2285 }
2286 SkillDependencyManifest::load_from_path(&dependencies_path).map(Some)
2287 }
2288
2289 fn rebuild_entry_registry(&mut self) -> Result<(), String> {
2292 #[derive(Clone)]
2295 struct EntrySeed {
2296 skill_storage_key: String,
2299 skill_id: String,
2302 local_name: String,
2305 base_name: String,
2308 directory_name: String,
2311 module_name: String,
2314 }
2315
2316 let mut seeds = Vec::new();
2317 for (skill_storage_key, skill) in &self.skills {
2318 for tool in skill.meta.entries() {
2319 let local_name = tool.name.trim().to_string();
2320 if seeds.iter().any(|seed: &EntrySeed| {
2321 seed.skill_storage_key == *skill_storage_key && seed.local_name == local_name
2322 }) {
2323 return Err(format!(
2324 "skill '{}' declares duplicate local entry name '{}'",
2325 skill.meta.effective_skill_id(),
2326 local_name
2327 ));
2328 }
2329
2330 let directory_name = skill
2331 .dir
2332 .file_name()
2333 .and_then(|value| value.to_str())
2334 .unwrap_or_default()
2335 .to_string();
2336 seeds.push(EntrySeed {
2337 skill_storage_key: skill_storage_key.clone(),
2338 skill_id: skill.meta.effective_skill_id().to_string(),
2339 local_name: local_name.clone(),
2340 base_name: skill.meta.tool_base_name(tool),
2341 directory_name,
2342 module_name: tool.lua_module.clone(),
2343 });
2344 }
2345 }
2346
2347 seeds.sort_by(|left, right| {
2348 (
2349 left.base_name.as_str(),
2350 left.directory_name.as_str(),
2351 left.skill_id.as_str(),
2352 left.local_name.as_str(),
2353 left.module_name.as_str(),
2354 )
2355 .cmp(&(
2356 right.base_name.as_str(),
2357 right.directory_name.as_str(),
2358 right.skill_id.as_str(),
2359 right.local_name.as_str(),
2360 right.module_name.as_str(),
2361 ))
2362 });
2363
2364 for skill in self.skills.values_mut() {
2365 skill.resolved_entry_names.clear();
2366 }
2367
2368 let mut registry = BTreeMap::new();
2369 let mut base_name_counters = HashMap::<String, usize>::new();
2370 let mut occupied_names = self
2371 .host_options
2372 .reserved_entry_names
2373 .iter()
2374 .cloned()
2375 .collect::<HashSet<String>>();
2376 for seed in seeds {
2377 let mut duplicate_index = *base_name_counters.get(&seed.base_name).unwrap_or(&0usize);
2378 let canonical_name = loop {
2379 duplicate_index += 1;
2380 let candidate_name = if duplicate_index == 1 {
2381 seed.base_name.clone()
2382 } else {
2383 format!("{}-{}", seed.base_name, duplicate_index)
2384 };
2385 if !occupied_names.contains(&candidate_name) {
2386 break candidate_name;
2387 }
2388 };
2389 base_name_counters.insert(seed.base_name.clone(), duplicate_index);
2390 occupied_names.insert(canonical_name.clone());
2391
2392 let resolved_target = ResolvedEntryTarget {
2393 canonical_name: canonical_name.clone(),
2394 skill_storage_key: seed.skill_storage_key.clone(),
2395 skill_id: seed.skill_id.clone(),
2396 local_name: seed.local_name.clone(),
2397 };
2398 registry.insert(canonical_name.clone(), resolved_target);
2399
2400 let skill = self
2401 .skills
2402 .get_mut(&seed.skill_storage_key)
2403 .ok_or_else(|| {
2404 format!(
2405 "internal error: missing loaded skill '{}' while building entry registry",
2406 seed.skill_storage_key
2407 )
2408 })?;
2409 skill
2410 .resolved_entry_names
2411 .insert(seed.local_name.clone(), canonical_name);
2412 }
2413
2414 self.entry_registry = registry;
2415 Ok(())
2416 }
2417
2418 pub fn load_from_dirs(
2421 &mut self,
2422 base_dir: &Path,
2423 override_dir: Option<&Path>,
2424 ) -> Result<(), Box<dyn std::error::Error>> {
2425 let mut skill_roots = Vec::new();
2426 if let Some(override_dir) = override_dir {
2427 skill_roots.push(RuntimeSkillRoot {
2428 name: "OVERRIDE".to_string(),
2429 skills_dir: override_dir.to_path_buf(),
2430 });
2431 }
2432 skill_roots.push(RuntimeSkillRoot {
2433 name: "ROOT".to_string(),
2434 skills_dir: base_dir.to_path_buf(),
2435 });
2436 self.load_from_roots(&skill_roots)
2437 }
2438
2439 pub fn load_from_roots(
2442 &mut self,
2443 skill_roots: &[RuntimeSkillRoot],
2444 ) -> Result<(), Box<dyn std::error::Error>> {
2445 if !skill_roots.is_empty() {
2446 self.refresh_skill_config_runtime_root(skill_roots)
2447 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
2448 }
2449 if skill_roots.iter().all(|root| !root.skills_dir.exists()) {
2450 return Ok(());
2451 }
2452
2453 for resolved_instance in collect_effective_skill_instances_from_roots(skill_roots)
2454 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?
2455 {
2456 let skill_name = resolved_instance.skill_id;
2457 if self.is_host_ignored_skill(&skill_name) {
2458 log_info(format!(
2459 "[LuaSkill] Skipped host-ignored skill '{}'",
2460 skill_name
2461 ));
2462 continue;
2463 }
2464 let resolved_root = RuntimeSkillRoot {
2465 name: resolved_instance.root_name.clone(),
2466 skills_dir: resolved_instance.skills_root.clone(),
2467 };
2468 let resolved_skill_manager = self
2469 .skill_manager_for(&resolved_root)
2470 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
2471 if !resolved_skill_manager.is_skill_enabled(&skill_name)? {
2472 log_warn(format!(
2473 "[LuaSkill] Skipped disabled skill '{}'",
2474 skill_name
2475 ));
2476 continue;
2477 }
2478 let actual_dir = resolved_instance.actual_dir;
2479 log_info(format!(
2480 "[LuaSkill] Loaded '{}' from root '{}'",
2481 skill_name, resolved_instance.root_name
2482 ));
2483
2484 if let Err(error) = self.ensure_skill_dependencies(&resolved_root, &actual_dir) {
2485 log_error(format!(
2486 "[LuaSkill] Failed to prepare dependencies for {}: {}",
2487 skill_name, error
2488 ));
2489 continue;
2490 }
2491
2492 if let Err(e) = self.load_single_skill(&actual_dir, &resolved_instance.root_name) {
2493 log_error(format!("[LuaSkill] Failed to load {}: {}", skill_name, e));
2494 }
2495 }
2496
2497 self.rebuild_entry_registry()
2498 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
2499
2500 self.pool
2501 .prewarm(|| self.create_vm())
2502 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
2503 self.runlua_pool
2504 .prewarm(|| {
2505 Self::create_runlua_vm(
2506 &self.skills,
2507 &self.entry_registry,
2508 self.host_options.clone(),
2509 self.skill_config_store.clone(),
2510 self.lancedb_host.clone(),
2511 self.sqlite_host.clone(),
2512 )
2513 })
2514 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
2515
2516 log_info(format!("[LuaSkill] {} skills loaded", self.skills.len()));
2517 Ok(())
2518 }
2519
2520 pub fn reload_from_dirs(
2523 &mut self,
2524 base_dir: &Path,
2525 override_dir: Option<&Path>,
2526 ) -> Result<(), Box<dyn std::error::Error>> {
2527 let mut skill_roots = Vec::new();
2528 if let Some(override_dir) = override_dir {
2529 skill_roots.push(RuntimeSkillRoot {
2530 name: "OVERRIDE".to_string(),
2531 skills_dir: override_dir.to_path_buf(),
2532 });
2533 }
2534 skill_roots.push(RuntimeSkillRoot {
2535 name: "ROOT".to_string(),
2536 skills_dir: base_dir.to_path_buf(),
2537 });
2538 self.reload_from_roots(&skill_roots)
2539 }
2540
2541 pub fn reload_from_roots(
2544 &mut self,
2545 skill_roots: &[RuntimeSkillRoot],
2546 ) -> Result<(), Box<dyn std::error::Error>> {
2547 let previous_entries = self.list_entries();
2548 self.reset_runtime_state();
2549 self.load_from_roots(skill_roots)?;
2550 self.emit_entry_registry_delta(previous_entries);
2551 Ok(())
2552 }
2553
2554 fn mutate_skill_state_and_reload(
2557 &mut self,
2558 plane: SkillOperationPlane,
2559 action: crate::skill::manager::SkillLifecycleAction,
2560 skill_roots: &[RuntimeSkillRoot],
2561 skill_id: &str,
2562 reason: Option<&str>,
2563 ) -> Result<(), Box<dyn std::error::Error>> {
2564 validate_luaskills_identifier(skill_id, "skill_id")
2565 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
2566 let resolved_instance = resolve_declared_skill_instance_from_roots(skill_roots, skill_id)
2567 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?
2568 .ok_or_else(|| -> Box<dyn std::error::Error> {
2569 format!("declared skill instance '{}' not found", skill_id).into()
2570 })?;
2571 let resolved_root = RuntimeSkillRoot {
2572 name: resolved_instance.root_name.clone(),
2573 skills_dir: resolved_instance.skills_root.clone(),
2574 };
2575 let manager = self
2576 .skill_manager_for(&resolved_root)
2577 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
2578 let removed_dependency_manifest =
2579 if action == crate::skill::manager::SkillLifecycleAction::Uninstall {
2580 let dependencies_path = resolved_instance.actual_dir.join("dependencies.yaml");
2581 if dependencies_path.exists() {
2582 Some(
2583 SkillDependencyManifest::load_from_path(&dependencies_path)
2584 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?,
2585 )
2586 } else {
2587 None
2588 }
2589 } else {
2590 None
2591 };
2592 if let Err(error) = manager.guard_operation(plane, action, skill_id) {
2593 self.emit_skill_lifecycle_event(
2594 plane,
2595 action,
2596 skill_id,
2597 Some(resolved_instance.root_name.clone()),
2598 Some(resolved_instance.actual_dir.display().to_string()),
2599 "blocked",
2600 Some(error.clone()),
2601 );
2602 return Err(error.into());
2603 }
2604 let action_result = match action {
2605 crate::skill::manager::SkillLifecycleAction::Disable => manager
2606 .disable_skill_in_plane(plane, skill_id, reason)
2607 .map_err(|error| -> Box<dyn std::error::Error> { error.into() }),
2608 crate::skill::manager::SkillLifecycleAction::Enable => manager
2609 .enable_skill_in_plane(plane, skill_id)
2610 .map_err(|error| -> Box<dyn std::error::Error> { error.into() }),
2611 crate::skill::manager::SkillLifecycleAction::Uninstall => manager
2612 .uninstall_skill_at_path_in_plane(plane, skill_id, &resolved_instance.actual_dir)
2613 .map(|_| ())
2614 .map_err(|error| -> Box<dyn std::error::Error> { error.into() }),
2615 _ => {
2616 return Err(format!("unsupported state mutation action {:?}", action).into());
2617 }
2618 };
2619 if let Err(error) = action_result {
2620 let message = error.to_string();
2621 self.emit_skill_lifecycle_event(
2622 plane,
2623 action,
2624 skill_id,
2625 Some(resolved_instance.root_name.clone()),
2626 Some(resolved_instance.actual_dir.display().to_string()),
2627 "failed",
2628 Some(message),
2629 );
2630 return Err(error);
2631 }
2632 if action == crate::skill::manager::SkillLifecycleAction::Uninstall {
2633 let dependency_manager = DependencyManager::new(
2634 self.dependency_manager_config_for(&resolved_root)
2635 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?,
2636 );
2637 dependency_manager
2638 .cleanup_uninstalled_skill_dependencies_from_roots(
2639 skill_roots,
2640 skill_id,
2641 removed_dependency_manifest.as_ref(),
2642 )
2643 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
2644 }
2645 self.reload_from_roots(skill_roots)?;
2646 self.emit_skill_lifecycle_event(
2647 plane,
2648 action,
2649 skill_id,
2650 Some(resolved_instance.root_name.clone()),
2651 Some(resolved_instance.actual_dir.display().to_string()),
2652 "completed",
2653 None,
2654 );
2655 Ok(())
2656 }
2657
2658 fn remove_skill_database_dir(
2661 &self,
2662 database_root: &Path,
2663 skill_id: &str,
2664 remove_requested: bool,
2665 database_label: &str,
2666 ) -> Result<(bool, bool), Box<dyn std::error::Error>> {
2667 if !remove_requested {
2668 return Ok((false, true));
2669 }
2670 let database_dir = database_root.join(database_label).join(skill_id);
2671 if !database_dir.exists() {
2672 return Ok((false, false));
2673 }
2674 fs::remove_dir_all(&database_dir).map_err(|error| {
2675 format!(
2676 "failed to remove {database_label} directory {}: {}",
2677 database_dir.display(),
2678 error
2679 )
2680 })?;
2681 Ok((true, false))
2682 }
2683
2684 fn uninstall_skill_and_reload(
2687 &mut self,
2688 plane: SkillOperationPlane,
2689 skill_roots: &[RuntimeSkillRoot],
2690 skill_id: &str,
2691 options: &SkillUninstallOptions,
2692 ) -> Result<SkillUninstallResult, Box<dyn std::error::Error>> {
2693 validate_luaskills_identifier(skill_id, "skill_id")
2694 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
2695 let resolved_instance = resolve_effective_skill_instance_from_roots(skill_roots, skill_id)
2696 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?
2697 .ok_or_else(|| -> Box<dyn std::error::Error> {
2698 format!("effective skill instance '{}' not found", skill_id).into()
2699 })?;
2700 let resolved_root = RuntimeSkillRoot {
2701 name: resolved_instance.root_name.clone(),
2702 skills_dir: resolved_instance.skills_root.clone(),
2703 };
2704 let manager = self
2705 .skill_manager_for(&resolved_root)
2706 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
2707 let dependencies_path = resolved_instance.actual_dir.join("dependencies.yaml");
2708 let removed_dependency_manifest = if dependencies_path.exists() {
2709 Some(
2710 SkillDependencyManifest::load_from_path(&dependencies_path)
2711 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?,
2712 )
2713 } else {
2714 None
2715 };
2716 if let Err(error) = manager.guard_operation(
2717 plane,
2718 crate::skill::manager::SkillLifecycleAction::Uninstall,
2719 skill_id,
2720 ) {
2721 self.emit_skill_lifecycle_event(
2722 plane,
2723 crate::skill::manager::SkillLifecycleAction::Uninstall,
2724 skill_id,
2725 Some(resolved_instance.root_name.clone()),
2726 Some(resolved_instance.actual_dir.display().to_string()),
2727 "blocked",
2728 Some(error.clone()),
2729 );
2730 return Err(error.into());
2731 }
2732 let prepared_uninstall = match manager.prepare_uninstall_skill_at_path_in_plane(
2733 plane,
2734 skill_id,
2735 &resolved_instance.actual_dir,
2736 ) {
2737 Ok(prepared) => prepared,
2738 Err(error) => {
2739 let message = error.to_string();
2740 self.emit_skill_lifecycle_event(
2741 plane,
2742 crate::skill::manager::SkillLifecycleAction::Uninstall,
2743 skill_id,
2744 Some(resolved_instance.root_name.clone()),
2745 Some(resolved_instance.actual_dir.display().to_string()),
2746 "failed",
2747 Some(message),
2748 );
2749 return Err(error.into());
2750 }
2751 };
2752 if let Err(reload_error) = self.reload_from_roots(skill_roots) {
2753 let rollback_error = manager.rollback_prepared_skill_uninstall(&prepared_uninstall);
2754 let restore_error = self.reload_from_roots(skill_roots);
2755 let rollback_message = rollback_error
2756 .err()
2757 .map(|error| format!(" rollback failed: {}", error))
2758 .unwrap_or_default();
2759 let restore_message = restore_error
2760 .err()
2761 .map(|error| format!(" runtime restore failed: {}", error))
2762 .unwrap_or_default();
2763 let message = format!(
2764 "Failed to reload LuaSkills after uninstall: {}.{}{}",
2765 reload_error, rollback_message, restore_message
2766 );
2767 self.emit_skill_lifecycle_event(
2768 plane,
2769 crate::skill::manager::SkillLifecycleAction::Uninstall,
2770 skill_id,
2771 Some(resolved_instance.root_name.clone()),
2772 Some(resolved_instance.actual_dir.display().to_string()),
2773 "failed",
2774 Some(message.clone()),
2775 );
2776 return Err(message.into());
2777 }
2778 let mut result = match manager.commit_prepared_skill_uninstall(&prepared_uninstall) {
2779 Ok(result) => result,
2780 Err(error) => {
2781 let rollback_error = manager.rollback_prepared_skill_uninstall(&prepared_uninstall);
2782 let restore_error = self.reload_from_roots(skill_roots);
2783 let rollback_message = rollback_error
2784 .err()
2785 .map(|rollback| format!(" rollback failed: {}", rollback))
2786 .unwrap_or_default();
2787 let restore_message = restore_error
2788 .err()
2789 .map(|restore| format!(" runtime restore failed: {}", restore))
2790 .unwrap_or_default();
2791 let message = format!(
2792 "Failed to finalize uninstall: {}.{}{}",
2793 error, rollback_message, restore_message
2794 );
2795 self.emit_skill_lifecycle_event(
2796 plane,
2797 crate::skill::manager::SkillLifecycleAction::Uninstall,
2798 skill_id,
2799 Some(resolved_instance.root_name.clone()),
2800 Some(resolved_instance.actual_dir.display().to_string()),
2801 "failed",
2802 Some(message.clone()),
2803 );
2804 return Err(message.into());
2805 }
2806 };
2807 let dependency_manager = DependencyManager::new(
2808 self.dependency_manager_config_for(&resolved_root)
2809 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?,
2810 );
2811 if let Err(error) = dependency_manager.cleanup_uninstalled_skill_dependencies_from_roots(
2812 skill_roots,
2813 skill_id,
2814 removed_dependency_manifest.as_ref(),
2815 ) {
2816 log_warn(format!(
2817 "[LuaSkills:uninstall] Stale dependency cleanup warning for skill '{}': {}",
2818 skill_id, error
2819 ));
2820 result.message = format!(
2821 "{} (warning: stale dependency cleanup failed: {})",
2822 result.message, error
2823 );
2824 }
2825 let (sqlite_removed, sqlite_retained) = match self.remove_skill_database_dir(
2826 &self.database_root_for(&resolved_root),
2827 skill_id,
2828 options.remove_sqlite,
2829 "sqlite",
2830 ) {
2831 Ok(result) => result,
2832 Err(error) => {
2833 log_warn(format!(
2834 "[LuaSkills:uninstall] SQLite cleanup warning for skill '{}': {}",
2835 skill_id, error
2836 ));
2837 result.message = format!(
2838 "{} (warning: sqlite cleanup failed: {})",
2839 result.message, error
2840 );
2841 (false, false)
2842 }
2843 };
2844 let (lancedb_removed, lancedb_retained) = match self.remove_skill_database_dir(
2845 &self.database_root_for(&resolved_root),
2846 skill_id,
2847 options.remove_lancedb,
2848 "lancedb",
2849 ) {
2850 Ok(result) => result,
2851 Err(error) => {
2852 log_warn(format!(
2853 "[LuaSkills:uninstall] LanceDB cleanup warning for skill '{}': {}",
2854 skill_id, error
2855 ));
2856 result.message = format!(
2857 "{} (warning: lancedb cleanup failed: {})",
2858 result.message, error
2859 );
2860 (false, false)
2861 }
2862 };
2863 result.sqlite_removed = sqlite_removed;
2864 result.sqlite_retained = sqlite_retained;
2865 result.lancedb_removed = lancedb_removed;
2866 result.lancedb_retained = lancedb_retained;
2867 let summary = format!(
2868 "skill package removed={} sqlite_removed={} sqlite_retained={} lancedb_removed={} lancedb_retained={}",
2869 result.skill_removed,
2870 result.sqlite_removed,
2871 result.sqlite_retained,
2872 result.lancedb_removed,
2873 result.lancedb_retained
2874 );
2875 result.message = if result.message.is_empty() {
2876 summary
2877 } else {
2878 format!("{}; {}", summary, result.message)
2879 };
2880 self.emit_skill_lifecycle_event(
2881 plane,
2882 crate::skill::manager::SkillLifecycleAction::Uninstall,
2883 skill_id,
2884 Some(resolved_instance.root_name.clone()),
2885 Some(resolved_instance.actual_dir.display().to_string()),
2886 "completed",
2887 Some(result.message.clone()),
2888 );
2889 Ok(result)
2890 }
2891
2892 fn apply_skill_request(
2895 &mut self,
2896 plane: SkillOperationPlane,
2897 action: crate::skill::manager::SkillLifecycleAction,
2898 skill_roots: &[RuntimeSkillRoot],
2899 request: &SkillInstallRequest,
2900 ) -> Result<SkillApplyResult, Box<dyn std::error::Error>> {
2901 if !matches!(
2902 action,
2903 crate::skill::manager::SkillLifecycleAction::Install
2904 | crate::skill::manager::SkillLifecycleAction::Update
2905 ) {
2906 return Err(format!("unsupported apply action {:?}", action).into());
2907 }
2908 let target_root = match action {
2909 crate::skill::manager::SkillLifecycleAction::Install => {
2910 self.reference_skill_root(skill_roots)?.clone()
2911 }
2912 crate::skill::manager::SkillLifecycleAction::Update => {
2913 let target_skill_id = request
2914 .skill_id
2915 .as_deref()
2916 .map(str::trim)
2917 .filter(|value| !value.is_empty())
2918 .map(ToOwned::to_owned)
2919 .or_else(|| {
2920 request
2921 .source
2922 .as_deref()
2923 .and_then(|value| value.trim().rsplit('/').next())
2924 .map(str::trim)
2925 .filter(|value| !value.is_empty())
2926 .map(ToOwned::to_owned)
2927 })
2928 .ok_or_else(|| -> Box<dyn std::error::Error> {
2929 "update request requires skill_id or one derivable source".into()
2930 })?;
2931 let resolved_instance =
2932 resolve_declared_skill_instance_from_roots(skill_roots, &target_skill_id)
2933 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?
2934 .ok_or_else(|| -> Box<dyn std::error::Error> {
2935 format!("skill '{}' is not installed", target_skill_id).into()
2936 })?;
2937 RuntimeSkillRoot {
2938 name: resolved_instance.root_name,
2939 skills_dir: resolved_instance.skills_root,
2940 }
2941 }
2942 _ => unreachable!("unsupported apply action should have returned early"),
2943 };
2944 let previous_dependency_manifest =
2945 if action == crate::skill::manager::SkillLifecycleAction::Update {
2946 let target_skill_id = request
2947 .skill_id
2948 .as_deref()
2949 .map(str::trim)
2950 .filter(|value| !value.is_empty())
2951 .map(ToOwned::to_owned)
2952 .or_else(|| {
2953 request
2954 .source
2955 .as_deref()
2956 .and_then(|value| value.trim().rsplit('/').next())
2957 .map(str::trim)
2958 .filter(|value| !value.is_empty())
2959 .map(ToOwned::to_owned)
2960 });
2961 if let Some(target_skill_id) = target_skill_id {
2962 resolve_declared_skill_instance_from_roots(skill_roots, &target_skill_id)
2963 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?
2964 .and_then(|resolved| {
2965 self.load_skill_dependency_manifest(&resolved.actual_dir)
2966 .transpose()
2967 })
2968 .transpose()
2969 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?
2970 } else {
2971 None
2972 }
2973 } else {
2974 None
2975 };
2976 let manager = self
2977 .skill_manager_for(&target_root)
2978 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
2979 let prepared = match action {
2980 crate::skill::manager::SkillLifecycleAction::Install => manager
2981 .prepare_install_skill(plane, skill_roots, request)
2982 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?,
2983 crate::skill::manager::SkillLifecycleAction::Update => manager
2984 .prepare_update_skill(plane, skill_roots, request)
2985 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?,
2986 _ => unreachable!("unsupported apply action should have returned early"),
2987 };
2988 let mut result = match &prepared {
2989 PreparedSkillApply::Immediate(result) => result.clone(),
2990 PreparedSkillApply::Install(_) | PreparedSkillApply::Update(_) => {
2991 if let Err(reload_error) = self.reload_from_roots(skill_roots) {
2992 let rollback_error = manager.rollback_prepared_skill_apply(&prepared);
2993 let restore_error = self.reload_from_roots(skill_roots);
2994 let rollback_message = rollback_error
2995 .err()
2996 .map(|error| format!(" rollback failed: {}", error))
2997 .unwrap_or_default();
2998 let restore_message = restore_error
2999 .err()
3000 .map(|error| format!(" runtime restore failed: {}", error))
3001 .unwrap_or_default();
3002 return Err(format!(
3003 "Failed to reload LuaSkills after {:?}: {}.{}{}",
3004 action, reload_error, rollback_message, restore_message
3005 )
3006 .into());
3007 }
3008
3009 let committed = manager.commit_prepared_skill_apply(&prepared).map_err(
3010 |error| -> Box<dyn std::error::Error> {
3011 let rollback_error = manager.rollback_prepared_skill_apply(&prepared);
3012 let restore_error = self.reload_from_roots(skill_roots);
3013 let rollback_message = rollback_error
3014 .err()
3015 .map(|rollback| format!(" rollback failed: {}", rollback))
3016 .unwrap_or_default();
3017 let restore_message = restore_error
3018 .err()
3019 .map(|restore| format!(" runtime restore failed: {}", restore))
3020 .unwrap_or_default();
3021 format!(
3022 "Failed to finalize {:?}: {}.{}{}",
3023 action, error, rollback_message, restore_message
3024 )
3025 .into()
3026 },
3027 )?;
3028 committed
3029 }
3030 };
3031 if action == crate::skill::manager::SkillLifecycleAction::Update
3032 && result.status == "updated"
3033 {
3034 let current_dependency_manifest =
3035 resolve_declared_skill_instance_from_roots(skill_roots, &result.skill_id)
3036 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?
3037 .and_then(|resolved| {
3038 self.load_skill_dependency_manifest(&resolved.actual_dir)
3039 .transpose()
3040 })
3041 .transpose()
3042 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
3043 let dependency_manager = DependencyManager::new(
3044 self.dependency_manager_config_for(&target_root)
3045 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?,
3046 );
3047 if let Err(error) = dependency_manager.cleanup_updated_skill_dependencies(
3048 &result.skill_id,
3049 previous_dependency_manifest.as_ref(),
3050 current_dependency_manifest.as_ref(),
3051 ) {
3052 log_warn(format!(
3053 "[LuaSkills:update] Stale dependency cleanup warning for skill '{}': {}",
3054 result.skill_id, error
3055 ));
3056 result.message = format!(
3057 "{} (warning: stale dependency cleanup failed: {})",
3058 result.message, error
3059 );
3060 }
3061 }
3062 let resolved_instance =
3063 resolve_declared_skill_instance_from_roots(skill_roots, &result.skill_id)
3064 .map_err(|error| -> Box<dyn std::error::Error> { error.into() })?;
3065 self.emit_skill_lifecycle_event(
3066 plane,
3067 action,
3068 &result.skill_id,
3069 resolved_instance
3070 .as_ref()
3071 .map(|instance| instance.root_name.clone()),
3072 resolved_instance
3073 .as_ref()
3074 .map(|instance| instance.actual_dir.display().to_string()),
3075 &result.status,
3076 Some(result.message.clone()),
3077 );
3078 Ok(result)
3079 }
3080
3081 pub fn disable_skill(
3084 &mut self,
3085 base_dir: &Path,
3086 override_dir: Option<&Path>,
3087 skill_id: &str,
3088 reason: Option<&str>,
3089 ) -> Result<(), Box<dyn std::error::Error>> {
3090 let mut skill_roots = Vec::new();
3091 if let Some(override_dir) = override_dir {
3092 skill_roots.push(RuntimeSkillRoot {
3093 name: "OVERRIDE".to_string(),
3094 skills_dir: override_dir.to_path_buf(),
3095 });
3096 }
3097 skill_roots.push(RuntimeSkillRoot {
3098 name: "ROOT".to_string(),
3099 skills_dir: base_dir.to_path_buf(),
3100 });
3101 self.disable_skill_in_roots(&skill_roots, skill_id, reason)
3102 }
3103
3104 pub fn disable_skill_in_roots(
3107 &mut self,
3108 skill_roots: &[RuntimeSkillRoot],
3109 skill_id: &str,
3110 reason: Option<&str>,
3111 ) -> Result<(), Box<dyn std::error::Error>> {
3112 self.mutate_skill_state_and_reload(
3113 SkillOperationPlane::Skills,
3114 crate::skill::manager::SkillLifecycleAction::Disable,
3115 skill_roots,
3116 skill_id,
3117 reason,
3118 )
3119 }
3120
3121 pub fn system_disable_skill(
3124 &mut self,
3125 base_dir: &Path,
3126 override_dir: Option<&Path>,
3127 skill_id: &str,
3128 reason: Option<&str>,
3129 ) -> Result<(), Box<dyn std::error::Error>> {
3130 let mut skill_roots = Vec::new();
3131 if let Some(override_dir) = override_dir {
3132 skill_roots.push(RuntimeSkillRoot {
3133 name: "OVERRIDE".to_string(),
3134 skills_dir: override_dir.to_path_buf(),
3135 });
3136 }
3137 skill_roots.push(RuntimeSkillRoot {
3138 name: "ROOT".to_string(),
3139 skills_dir: base_dir.to_path_buf(),
3140 });
3141 self.system_disable_skill_in_roots(&skill_roots, skill_id, reason)
3142 }
3143
3144 pub fn system_disable_skill_in_roots(
3147 &mut self,
3148 skill_roots: &[RuntimeSkillRoot],
3149 skill_id: &str,
3150 reason: Option<&str>,
3151 ) -> Result<(), Box<dyn std::error::Error>> {
3152 self.mutate_skill_state_and_reload(
3153 SkillOperationPlane::System,
3154 crate::skill::manager::SkillLifecycleAction::Disable,
3155 skill_roots,
3156 skill_id,
3157 reason,
3158 )
3159 }
3160
3161 pub fn enable_skill(
3164 &mut self,
3165 skill_roots: &[RuntimeSkillRoot],
3166 skill_id: &str,
3167 ) -> Result<(), Box<dyn std::error::Error>> {
3168 self.mutate_skill_state_and_reload(
3169 SkillOperationPlane::Skills,
3170 crate::skill::manager::SkillLifecycleAction::Enable,
3171 skill_roots,
3172 skill_id,
3173 None,
3174 )
3175 }
3176
3177 pub fn system_enable_skill(
3180 &mut self,
3181 skill_roots: &[RuntimeSkillRoot],
3182 skill_id: &str,
3183 ) -> Result<(), Box<dyn std::error::Error>> {
3184 self.mutate_skill_state_and_reload(
3185 SkillOperationPlane::System,
3186 crate::skill::manager::SkillLifecycleAction::Enable,
3187 skill_roots,
3188 skill_id,
3189 None,
3190 )
3191 }
3192
3193 pub fn uninstall_skill(
3196 &mut self,
3197 skill_roots: &[RuntimeSkillRoot],
3198 skill_id: &str,
3199 options: &SkillUninstallOptions,
3200 ) -> Result<SkillUninstallResult, Box<dyn std::error::Error>> {
3201 self.uninstall_skill_and_reload(SkillOperationPlane::Skills, skill_roots, skill_id, options)
3202 }
3203
3204 pub fn system_uninstall_skill(
3207 &mut self,
3208 skill_roots: &[RuntimeSkillRoot],
3209 skill_id: &str,
3210 options: &SkillUninstallOptions,
3211 ) -> Result<SkillUninstallResult, Box<dyn std::error::Error>> {
3212 self.uninstall_skill_and_reload(SkillOperationPlane::System, skill_roots, skill_id, options)
3213 }
3214
3215 pub fn install_skill(
3218 &mut self,
3219 skill_roots: &[RuntimeSkillRoot],
3220 request: &SkillInstallRequest,
3221 ) -> Result<SkillApplyResult, Box<dyn std::error::Error>> {
3222 self.apply_skill_request(
3223 SkillOperationPlane::Skills,
3224 crate::skill::manager::SkillLifecycleAction::Install,
3225 skill_roots,
3226 request,
3227 )
3228 }
3229
3230 pub fn system_install_skill(
3233 &mut self,
3234 skill_roots: &[RuntimeSkillRoot],
3235 request: &SkillInstallRequest,
3236 ) -> Result<SkillApplyResult, Box<dyn std::error::Error>> {
3237 self.apply_skill_request(
3238 SkillOperationPlane::System,
3239 crate::skill::manager::SkillLifecycleAction::Install,
3240 skill_roots,
3241 request,
3242 )
3243 }
3244
3245 pub fn update_skill(
3248 &mut self,
3249 skill_roots: &[RuntimeSkillRoot],
3250 request: &SkillInstallRequest,
3251 ) -> Result<SkillApplyResult, Box<dyn std::error::Error>> {
3252 self.apply_skill_request(
3253 SkillOperationPlane::Skills,
3254 crate::skill::manager::SkillLifecycleAction::Update,
3255 skill_roots,
3256 request,
3257 )
3258 }
3259
3260 pub fn system_update_skill(
3263 &mut self,
3264 skill_roots: &[RuntimeSkillRoot],
3265 request: &SkillInstallRequest,
3266 ) -> Result<SkillApplyResult, Box<dyn std::error::Error>> {
3267 self.apply_skill_request(
3268 SkillOperationPlane::System,
3269 crate::skill::manager::SkillLifecycleAction::Update,
3270 skill_roots,
3271 request,
3272 )
3273 }
3274
3275 fn reset_runtime_state(&mut self) {
3278 let pool_config = self.pool.config;
3279 let runlua_pool_config = self.runlua_pool.config;
3280 self.skills.clear();
3281 self.entry_registry.clear();
3282 self.lancedb_host = None;
3283 self.sqlite_host = None;
3284 self.pool = Arc::new(LuaVmPool::new(pool_config));
3285 self.runlua_pool = Arc::new(LuaVmPool::new(runlua_pool_config));
3286 }
3287
3288 fn emit_skill_lifecycle_event(
3291 &self,
3292 plane: SkillOperationPlane,
3293 action: crate::skill::manager::SkillLifecycleAction,
3294 skill_id: &str,
3295 root_name: Option<String>,
3296 skill_dir: Option<String>,
3297 status: &str,
3298 message: Option<String>,
3299 ) {
3300 crate::host::callbacks::emit_skill_lifecycle_event(&RuntimeSkillLifecycleEvent {
3301 plane,
3302 action,
3303 skill_id: skill_id.to_string(),
3304 root_name,
3305 skill_dir,
3306 status: status.to_string(),
3307 message,
3308 });
3309 }
3310
3311 fn emit_entry_registry_delta(&self, previous_entries: Vec<RuntimeEntryDescriptor>) {
3314 let current_entries = self.list_entries();
3315 let previous_map = previous_entries
3316 .into_iter()
3317 .map(|entry| (entry.canonical_name.clone(), entry))
3318 .collect::<BTreeMap<String, RuntimeEntryDescriptor>>();
3319 let current_map = current_entries
3320 .into_iter()
3321 .map(|entry| (entry.canonical_name.clone(), entry))
3322 .collect::<BTreeMap<String, RuntimeEntryDescriptor>>();
3323
3324 let mut added_entries = Vec::new();
3325 let mut updated_entries = Vec::new();
3326 let mut removed_entry_names = Vec::new();
3327
3328 for (canonical_name, current_entry) in ¤t_map {
3329 match previous_map.get(canonical_name) {
3330 None => added_entries.push(current_entry.clone()),
3331 Some(previous_entry) if previous_entry != current_entry => {
3332 updated_entries.push(current_entry.clone());
3333 }
3334 Some(_) => {}
3335 }
3336 }
3337
3338 for canonical_name in previous_map.keys() {
3339 if !current_map.contains_key(canonical_name) {
3340 removed_entry_names.push(canonical_name.clone());
3341 }
3342 }
3343
3344 if added_entries.is_empty() && updated_entries.is_empty() && removed_entry_names.is_empty()
3345 {
3346 return;
3347 }
3348
3349 crate::host::callbacks::emit_entry_registry_delta(&RuntimeEntryRegistryDelta {
3350 added_entries,
3351 removed_entry_names,
3352 updated_entries,
3353 });
3354 }
3355
3356 fn load_single_skill(
3358 &mut self,
3359 dir: &Path,
3360 root_name: &str,
3361 ) -> Result<(), Box<dyn std::error::Error>> {
3362 let skill_yaml = dir.join("skill.yaml");
3363 if !skill_yaml.exists() {
3364 return Err(format!("skill.yaml not found in {}", dir.display()).into());
3365 }
3366
3367 let yaml_str = std::fs::read_to_string(&skill_yaml)?;
3368 let yaml_value: serde_yaml::Value = serde_yaml::from_str(&yaml_str)?;
3369 if yaml_value.as_mapping().is_some_and(|mapping| {
3370 mapping.contains_key(serde_yaml::Value::String("skill_id".to_string()))
3371 }) {
3372 return Err(format!("skill {} must not declare skill_id in skill.yaml; directory name is the only skill_id", dir.display())
3373 .into());
3374 }
3375 let mut meta: SkillMeta = serde_yaml::from_value(yaml_value)?;
3376 let directory_skill_id = dir
3377 .file_name()
3378 .and_then(|value| value.to_str())
3379 .ok_or_else(|| format!("invalid skill directory name: {}", dir.display()))?
3380 .trim()
3381 .to_string();
3382 validate_luaskills_identifier(&directory_skill_id, "skill directory name")
3383 .map_err(|error| format!("skill {}: {}", dir.display(), error))?;
3384 meta.bind_directory_skill_id(directory_skill_id.clone());
3385
3386 if !meta.is_enabled() {
3387 log_info(format!(
3388 "[LuaSkill] Skip disabled skill '{}'",
3389 meta.effective_skill_id()
3390 ));
3391 return Ok(());
3392 }
3393 validate_luaskills_identifier(meta.effective_skill_id(), "skill_id")
3394 .map_err(|error| format!("skill {}: {}", meta.name, error))?;
3395 validate_luaskills_version(meta.version(), "version")
3396 .map_err(|error| format!("skill {}: {}", meta.effective_skill_id(), error))?;
3397
3398 if meta.entries.is_empty() {
3399 return Err(format!("skill {} must declare at least one entry", meta.name).into());
3400 }
3401
3402 for tool in meta.entries() {
3403 validate_luaskills_identifier(tool.name.trim(), "entry.name").map_err(|error| {
3404 format!("skill {} entry {}: {}", meta.name, tool.name.trim(), error)
3405 })?;
3406 if tool.lua_entry.trim().is_empty() || tool.lua_module.trim().is_empty() {
3407 return Err(format!(
3408 "skill {} declares entry {} but lua_entry/lua_module is missing",
3409 meta.name, tool.name
3410 )
3411 .into());
3412 }
3413
3414 validate_skill_relative_path(&tool.lua_entry, "runtime", "entry.lua_entry")
3415 .map_err(|error| format!("skill {} entry {}: {}", meta.name, tool.name, error))?;
3416
3417 let lua_path = tool_entry_path(dir, tool);
3418 if !lua_path.exists() {
3419 return Err(format!(
3420 "Lua entry {} not found in {}",
3421 tool.lua_entry,
3422 dir.display()
3423 )
3424 .into());
3425 }
3426 }
3427
3428 if !meta.help.main.file.trim().is_empty() {
3429 validate_skill_relative_path(&meta.help.main.file, "help", "help.main.file")
3430 .map_err(|error| format!("skill {} help main: {}", meta.name, error))?;
3431 }
3432 for topic in &meta.help.topics {
3433 validate_skill_relative_path(&topic.file, "help", "help.topic.file").map_err(
3434 |error| {
3435 format!(
3436 "skill {} help topic {}: {}",
3437 meta.name,
3438 topic.name.trim(),
3439 error
3440 )
3441 },
3442 )?;
3443 }
3444
3445 let effective_lancedb = meta.effective_lancedb();
3446 let lancedb_binding = if effective_lancedb.enable {
3447 if self.lancedb_host.is_none() {
3448 self.lancedb_host = Some(Arc::new(
3449 LanceDbSkillHost::new(
3450 self.host_options.as_ref().clone(),
3451 self.database_provider_callbacks.clone(),
3452 )
3453 .map_err(|error| {
3454 format!("Failed to initialize LanceDB skill host: {}", error)
3455 })?,
3456 ));
3457 }
3458
3459 let host = self
3460 .lancedb_host
3461 .as_ref()
3462 .ok_or("LanceDB skill host missing after initialization")?
3463 .clone();
3464
3465 Some(
3466 host.register_skill(root_name, meta.effective_skill_id(), dir, effective_lancedb)
3467 .map_err(|error| {
3468 format!(
3469 "Failed to register LanceDB for skill {}: {}",
3470 meta.effective_skill_id(),
3471 error
3472 )
3473 })?,
3474 )
3475 } else {
3476 None
3477 };
3478
3479 let effective_sqlite = meta.effective_sqlite();
3480 let sqlite_binding = if effective_sqlite.enable {
3481 if self.sqlite_host.is_none() {
3482 self.sqlite_host = Some(Arc::new(
3483 SqliteSkillHost::new(
3484 self.host_options.as_ref().clone(),
3485 self.database_provider_callbacks.clone(),
3486 )
3487 .map_err(|error| {
3488 format!("Failed to initialize SQLite skill host: {}", error)
3489 })?,
3490 ));
3491 }
3492
3493 let host = self
3494 .sqlite_host
3495 .as_ref()
3496 .ok_or("SQLite skill host missing after initialization")?
3497 .clone();
3498
3499 Some(
3500 host.register_skill(root_name, meta.effective_skill_id(), dir, effective_sqlite)
3501 .map_err(|error| {
3502 format!(
3503 "Failed to register SQLite for skill {}: {}",
3504 meta.effective_skill_id(),
3505 error
3506 )
3507 })?,
3508 )
3509 } else {
3510 None
3511 };
3512
3513 self.skills.insert(
3514 meta.effective_skill_id().to_string(),
3515 LoadedSkill {
3516 meta,
3517 dir: dir.to_path_buf(),
3518 root_name: root_name.to_string(),
3519 lancedb_binding,
3520 sqlite_binding,
3521 resolved_entry_names: HashMap::new(),
3522 },
3523 );
3524
3525 Ok(())
3526 }
3527
3528 fn create_vm(&self) -> Result<LuaVm, String> {
3531 let skills = Arc::new(self.skills.clone());
3532 let entry_registry = Arc::new(self.entry_registry.clone());
3533 let lua = unsafe { Lua::unsafe_new() };
3534 Self::setup_package_paths(&lua, self.host_options.as_ref())
3535 .map_err(|error| error.to_string())?;
3536 Self::register_vulcan_module(
3537 &lua,
3538 self.host_options.as_ref(),
3539 self.skill_config_store.clone(),
3540 )
3541 .map_err(|error| error.to_string())?;
3542 Self::populate_vulcan_luaexec_bridge(
3543 &lua,
3544 self.host_options.clone(),
3545 self.runlua_pool.clone(),
3546 self.skill_config_store.clone(),
3547 skills.clone(),
3548 entry_registry.clone(),
3549 self.lancedb_host.clone(),
3550 self.sqlite_host.clone(),
3551 )?;
3552 Self::register_skill_functions(&lua, skills.as_ref())?;
3553 Self::populate_vulcan_call_for_lua(
3554 &lua,
3555 skills.as_ref(),
3556 entry_registry.as_ref(),
3557 self.host_options.clone(),
3558 self.lancedb_host.clone(),
3559 self.sqlite_host.clone(),
3560 )?;
3561 Ok(LuaVm {
3562 lua,
3563 last_used_at: Instant::now(),
3564 })
3565 }
3566
3567 fn acquire_vm(&self) -> Result<LuaVmLease, String> {
3570 self.pool.acquire(|| self.create_vm())
3571 }
3572
3573 fn create_runlua_vm(
3576 skills: &HashMap<String, LoadedSkill>,
3577 entry_registry: &BTreeMap<String, ResolvedEntryTarget>,
3578 host_options: Arc<LuaRuntimeHostOptions>,
3579 skill_config_store: Arc<SkillConfigStore>,
3580 lancedb_host: Option<Arc<LanceDbSkillHost>>,
3581 sqlite_host: Option<Arc<SqliteSkillHost>>,
3582 ) -> Result<LuaVm, String> {
3583 let lua = unsafe { Lua::unsafe_new() };
3584 Self::setup_package_paths(&lua, host_options.as_ref())
3585 .map_err(|error| error.to_string())?;
3586 Self::register_vulcan_module(&lua, host_options.as_ref(), skill_config_store)
3587 .map_err(|error| error.to_string())?;
3588 Self::register_skill_functions(&lua, skills)?;
3589 Self::populate_vulcan_call_for_lua(
3590 &lua,
3591 skills,
3592 entry_registry,
3593 host_options,
3594 lancedb_host,
3595 sqlite_host,
3596 )?;
3597 Ok(LuaVm {
3598 lua,
3599 last_used_at: Instant::now(),
3600 })
3601 }
3602
3603 fn populate_vulcan_luaexec_bridge(
3606 lua: &Lua,
3607 host_options: Arc<LuaRuntimeHostOptions>,
3608 runlua_pool: Arc<LuaVmPool>,
3609 skill_config_store: Arc<SkillConfigStore>,
3610 skills: Arc<HashMap<String, LoadedSkill>>,
3611 entry_registry: Arc<BTreeMap<String, ResolvedEntryTarget>>,
3612 lancedb_host: Option<Arc<LanceDbSkillHost>>,
3613 sqlite_host: Option<Arc<SqliteSkillHost>>,
3614 ) -> Result<(), String> {
3615 let runtime_lua = get_vulcan_runtime_lua_table(lua)?;
3616
3617 let exec_fn = lua
3618 .create_function(move |lua, input: LuaValue| {
3619 let input_table = require_table_arg(input, "runtime.lua.exec", "input")?;
3620 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
3621 .map_err(mlua::Error::runtime)?;
3622 let mut request: RunLuaExecRequest =
3623 serde_json::from_value(input_json).map_err(|error| {
3624 mlua::Error::runtime(format!("luaexec input is invalid: {}", error))
3625 })?;
3626 let internal =
3627 get_vulcan_runtime_internal_table(lua).map_err(mlua::Error::runtime)?;
3628 let caller_tool_name: Option<String> =
3629 internal.get("tool_name").map_err(mlua::Error::runtime)?;
3630 request.caller_tool_name = caller_tool_name
3631 .map(|value| value.trim().to_string())
3632 .filter(|value| !value.is_empty());
3633 let rendered = LuaEngine::execute_runlua_request_inline_with_runtime(
3634 &request,
3635 runlua_pool.clone(),
3636 skills.clone(),
3637 entry_registry.clone(),
3638 host_options.clone(),
3639 skill_config_store.clone(),
3640 lancedb_host.clone(),
3641 sqlite_host.clone(),
3642 )
3643 .map_err(mlua::Error::runtime)?;
3644 Ok(LuaValue::String(
3645 lua.create_string(&rendered).map_err(mlua::Error::runtime)?,
3646 ))
3647 })
3648 .map_err(|error| format!("Failed to create vulcan.runtime.lua.exec: {}", error))?;
3649 runtime_lua
3650 .set("exec", exec_fn)
3651 .map_err(|error| format!("Failed to set vulcan.runtime.lua.exec: {}", error))?;
3652 Ok(())
3653 }
3654
3655 fn register_skill_functions(
3658 lua: &Lua,
3659 skills: &HashMap<String, LoadedSkill>,
3660 ) -> Result<(), String> {
3661 for skill in skills.values() {
3662 for tool in skill.meta.entries() {
3663 Self::compile_skill_into_lua(lua, skill, tool, false)?;
3664 }
3665 }
3666 Ok(())
3667 }
3668
3669 fn compile_skill_into_lua(
3672 lua: &Lua,
3673 skill: &LoadedSkill,
3674 tool: &crate::lua_skill::SkillToolMeta,
3675 always_reload: bool,
3676 ) -> Result<(), String> {
3677 let lua_path = tool_entry_path(&skill.dir, tool);
3678 let source = std::fs::read_to_string(&lua_path)
3679 .map_err(|error| format!("Failed to read {}: {}", lua_path.display(), error))?;
3680 if always_reload {
3681 log_info(format!(
3682 "[LuaSkill] Hot reload {}: {}",
3683 tool.lua_module,
3684 render_log_friendly_path(&lua_path)
3685 ));
3686 }
3687
3688 let chunk = lua.load(&source).set_name(&tool.lua_module);
3689 let outer: Function = chunk.into_function().map_err(|error| {
3690 format!(
3691 "Failed to compile skill '{}::{}': {}",
3692 skill.meta.name, tool.lua_module, error
3693 )
3694 })?;
3695 let handler: Function = outer.call(()).map_err(|error| {
3696 format!(
3697 "Failed to initialize skill '{}::{}': {}",
3698 skill.meta.name, tool.lua_module, error
3699 )
3700 })?;
3701 lua.globals()
3702 .set(format!("__skill_{}", tool.lua_module), handler)
3703 .map_err(|error| {
3704 format!(
3705 "Failed to register skill '{}::{}': {}",
3706 skill.meta.name, tool.lua_module, error
3707 )
3708 })?;
3709 Ok(())
3710 }
3711
3712 pub fn list_entries(&self) -> Vec<RuntimeEntryDescriptor> {
3715 self.entry_registry
3716 .values()
3717 .filter_map(|target| {
3718 let skill = self.skills.get(&target.skill_storage_key)?;
3719 let tool = skill.meta.find_tool_by_local_name(&target.local_name)?;
3720 Some(RuntimeEntryDescriptor {
3721 canonical_name: target.canonical_name.clone(),
3722 skill_id: target.skill_id.clone(),
3723 local_name: target.local_name.clone(),
3724 root_name: skill.root_name.clone(),
3725 skill_dir: skill.dir.display().to_string(),
3726 description: tool.description.clone(),
3727 parameters: tool
3728 .parameters
3729 .iter()
3730 .map(|parameter| RuntimeEntryParameterDescriptor {
3731 name: parameter.name.clone(),
3732 param_type: parameter.param_type.clone(),
3733 description: parameter.description.clone(),
3734 required: parameter.required,
3735 })
3736 .collect(),
3737 })
3738 })
3739 .collect()
3740 }
3741
3742 pub fn list_skill_help(&self) -> Vec<RuntimeSkillHelpDescriptor> {
3745 let mut descriptors = self
3746 .skills
3747 .values()
3748 .map(|skill| RuntimeSkillHelpDescriptor {
3749 skill_id: skill.meta.effective_skill_id().to_string(),
3750 skill_name: skill.meta.name.clone(),
3751 skill_version: skill.meta.version().to_string(),
3752 root_name: skill.root_name.clone(),
3753 skill_dir: skill.dir.display().to_string(),
3754 main: self.build_help_node_descriptor(skill, skill.meta.main_help(), true),
3755 flows: skill
3756 .meta
3757 .help_topics()
3758 .map(|topic| self.build_help_node_descriptor(skill, topic, false))
3759 .collect::<Vec<RuntimeHelpNodeDescriptor>>(),
3760 })
3761 .collect::<Vec<RuntimeSkillHelpDescriptor>>();
3762
3763 descriptors.sort_by(|left, right| left.skill_id.cmp(&right.skill_id));
3764 descriptors
3765 }
3766
3767 pub fn render_skill_help_detail(
3770 &self,
3771 skill_id: &str,
3772 flow_name: &str,
3773 request_context: Option<&RuntimeRequestContext>,
3774 ) -> Result<Option<RuntimeHelpDetail>, String> {
3775 let Some(skill) = self
3776 .skills
3777 .values()
3778 .find(|skill| skill.meta.effective_skill_id() == skill_id)
3779 else {
3780 return Ok(None);
3781 };
3782
3783 let normalized_flow_name = flow_name.trim();
3784 if normalized_flow_name.is_empty() {
3785 return Err("Help flow name must not be empty".to_string());
3786 }
3787
3788 let (selected_help, is_main) = if normalized_flow_name == "main" {
3789 (skill.meta.main_help(), true)
3790 } else {
3791 (
3792 skill
3793 .meta
3794 .find_help_topic(normalized_flow_name)
3795 .ok_or_else(|| {
3796 format!(
3797 "Skill '{}' does not declare help flow '{}'",
3798 skill.meta.effective_skill_id(),
3799 normalized_flow_name
3800 )
3801 })?,
3802 false,
3803 )
3804 };
3805
3806 let rendered_body =
3807 self.render_help_payload(skill, &selected_help.file, request_context)?;
3808 let descriptor = self.build_help_node_descriptor(skill, selected_help, is_main);
3809 Ok(Some(RuntimeHelpDetail {
3810 skill_id: skill.meta.effective_skill_id().to_string(),
3811 skill_name: skill.meta.name.clone(),
3812 skill_version: skill.meta.version().to_string(),
3813 root_name: skill.root_name.clone(),
3814 skill_dir: skill.dir.display().to_string(),
3815 flow_name: descriptor.flow_name,
3816 description: descriptor.description,
3817 related_entries: descriptor.related_entries,
3818 is_main: descriptor.is_main,
3819 content_type: "markdown".to_string(),
3820 content: rendered_body,
3821 }))
3822 }
3823
3824 fn build_help_node_descriptor(
3827 &self,
3828 skill: &LoadedSkill,
3829 help_node: &crate::lua_skill::SkillHelpNodeMeta,
3830 is_main: bool,
3831 ) -> RuntimeHelpNodeDescriptor {
3832 let flow_name = if is_main {
3833 "main".to_string()
3834 } else {
3835 help_node.name.trim().to_string()
3836 };
3837 let related_entries = if is_main {
3838 skill
3839 .meta
3840 .entries()
3841 .filter_map(|entry| {
3842 skill
3843 .resolved_tool_name(entry.name.trim())
3844 .map(str::to_string)
3845 })
3846 .collect::<Vec<String>>()
3847 } else {
3848 skill
3849 .meta
3850 .entries_for_help_topic(help_node.name.trim())
3851 .filter_map(|entry| {
3852 skill
3853 .resolved_tool_name(entry.name.trim())
3854 .map(str::to_string)
3855 })
3856 .collect::<Vec<String>>()
3857 };
3858
3859 RuntimeHelpNodeDescriptor {
3860 flow_name,
3861 description: help_node.description.trim().to_string(),
3862 related_entries,
3863 is_main,
3864 }
3865 }
3866
3867 pub fn prompt_argument_completions(
3870 &self,
3871 prompt_name: &str,
3872 argument_name: &str,
3873 ) -> Option<Vec<String>> {
3874 let _ = prompt_name;
3875 let _ = argument_name;
3876 None
3877 }
3878
3879 pub fn is_skill(&self, name: &str) -> bool {
3881 self.entry_registry.contains_key(name)
3882 }
3883
3884 pub fn skill_name_for_tool(&self, tool_name: &str) -> Option<String> {
3887 self.entry_registry
3888 .get(tool_name)
3889 .map(|target| target.skill_id.clone())
3890 }
3891
3892 pub fn list_skill_config_entries(
3895 &self,
3896 skill_id: Option<&str>,
3897 ) -> Result<Vec<SkillConfigEntry>, String> {
3898 self.skill_config_store.list_entries(skill_id)
3899 }
3900
3901 pub fn get_skill_config_value(
3904 &self,
3905 skill_id: &str,
3906 key: &str,
3907 ) -> Result<Option<String>, String> {
3908 self.skill_config_store.get_value(skill_id, key)
3909 }
3910
3911 pub fn set_skill_config_value(
3914 &mut self,
3915 skill_id: &str,
3916 key: &str,
3917 value: &str,
3918 ) -> Result<(), String> {
3919 self.skill_config_store.set_value(skill_id, key, value)
3920 }
3921
3922 pub fn delete_skill_config_value(&mut self, skill_id: &str, key: &str) -> Result<bool, String> {
3925 self.skill_config_store.delete_value(skill_id, key)
3926 }
3927
3928 fn populate_vulcan_request_context(
3931 lua: &Lua,
3932 invocation_context: Option<&LuaInvocationContext>,
3933 ) -> Result<(), String> {
3934 let context_table = get_vulcan_context_table(lua)?;
3935 let request_context =
3936 invocation_context.and_then(|context| context.request_context.as_ref());
3937 let context_value = match request_context {
3938 Some(context) => serde_json::to_value(context)
3939 .map_err(|error| format!("Failed to serialize request context: {}", error))?,
3940 None => Value::Object(serde_json::Map::new()),
3941 };
3942 let context_lua = json_value_to_lua(lua, &context_value)
3943 .map_err(|error| format!("Failed to convert request context to Lua: {}", error))?;
3944 let client_info_value = match &context_value {
3945 Value::Object(object) => object.get("client_info").cloned().unwrap_or(Value::Null),
3946 _ => Value::Null,
3947 };
3948 let client_capabilities_value = match &context_value {
3949 Value::Object(object) => object
3950 .get("client_capabilities")
3951 .cloned()
3952 .unwrap_or_else(|| Value::Object(serde_json::Map::new())),
3953 _ => Value::Object(serde_json::Map::new()),
3954 };
3955 let client_info_lua = json_value_to_lua(lua, &client_info_value)
3956 .map_err(|error| format!("Failed to convert client_info to Lua: {}", error))?;
3957 let client_capabilities_lua = json_value_to_lua(lua, &client_capabilities_value)
3958 .map_err(|error| format!("Failed to convert client_capabilities to Lua: {}", error))?;
3959 let client_budget_value = invocation_context
3960 .map(|context| context.client_budget.clone())
3961 .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
3962 let client_budget_lua = json_value_to_lua(lua, &client_budget_value)
3963 .map_err(|error| format!("Failed to convert client_budget to Lua: {}", error))?;
3964 let tool_config_value = invocation_context
3965 .map(|context| context.tool_config.clone())
3966 .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
3967 let tool_config_lua = json_value_to_lua(lua, &tool_config_value)
3968 .map_err(|error| format!("Failed to convert tool_config to Lua: {}", error))?;
3969
3970 context_table
3971 .set("request", context_lua)
3972 .map_err(|error| format!("Failed to set vulcan.context.request: {}", error))?;
3973 context_table
3974 .set("client_info", client_info_lua)
3975 .map_err(|error| format!("Failed to set vulcan.context.client_info: {}", error))?;
3976 context_table
3977 .set("client_capabilities", client_capabilities_lua)
3978 .map_err(|error| {
3979 format!(
3980 "Failed to set vulcan.context.client_capabilities: {}",
3981 error
3982 )
3983 })?;
3984 context_table
3985 .set("client_budget", client_budget_lua)
3986 .map_err(|error| format!("Failed to set vulcan.context.client_budget: {}", error))?;
3987 context_table
3988 .set("tool_config", tool_config_lua)
3989 .map_err(|error| format!("Failed to set vulcan.context.tool_config: {}", error))?;
3990 Ok(())
3991 }
3992
3993 fn populate_vulcan_lancedb_context(
3996 lua: &Lua,
3997 binding: Option<Arc<LanceDbSkillBinding>>,
3998 current_skill_name: Option<&str>,
3999 ) -> Result<(), String> {
4000 let vulcan: Table = lua
4001 .globals()
4002 .get("vulcan")
4003 .map_err(|error| format!("Failed to get vulcan module: {}", error))?;
4004
4005 let lancedb_table = lua
4006 .create_table()
4007 .map_err(|error| format!("Failed to create vulcan.lancedb table: {}", error))?;
4008
4009 let current_skill = current_skill_name.unwrap_or("");
4010 vulcan
4011 .set("__lancedb_skill_name", current_skill)
4012 .map_err(|error| format!("Failed to set vulcan.__lancedb_skill_name: {}", error))?;
4013
4014 if let Some(binding) = binding {
4015 lancedb_table
4016 .set("enabled", true)
4017 .map_err(|error| format!("Failed to set vulcan.lancedb.enabled: {}", error))?;
4018 let info_binding = binding.clone();
4019 let info_fn = lua
4020 .create_function(move |lua, ()| {
4021 json_value_to_lua(lua, &info_binding.info_json()).map_err(mlua::Error::external)
4022 })
4023 .map_err(|error| format!("Failed to create vulcan.lancedb.info: {}", error))?;
4024 lancedb_table
4025 .set("info", info_fn)
4026 .map_err(|error| format!("Failed to set vulcan.lancedb.info: {}", error))?;
4027
4028 let status_binding = binding.clone();
4029 let status_fn = lua
4030 .create_function(move |lua, ()| {
4031 json_value_to_lua(lua, &status_binding.status_json())
4032 .map_err(mlua::Error::external)
4033 })
4034 .map_err(|error| format!("Failed to create vulcan.lancedb.status: {}", error))?;
4035 lancedb_table
4036 .set("status", status_fn)
4037 .map_err(|error| format!("Failed to set vulcan.lancedb.status: {}", error))?;
4038
4039 let create_binding = binding.clone();
4040 let create_table_fn = lua
4041 .create_function(move |lua, input: LuaValue| {
4042 let input_table = require_table_arg(input, "lancedb.create_table", "input")?;
4043 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4044 .map_err(mlua::Error::runtime)?;
4045 let result = create_binding
4046 .create_table_json(&input_json)
4047 .map_err(mlua::Error::runtime)?;
4048 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4049 })
4050 .map_err(|error| {
4051 format!("Failed to create vulcan.lancedb.create_table: {}", error)
4052 })?;
4053 lancedb_table
4054 .set("create_table", create_table_fn)
4055 .map_err(|error| format!("Failed to set vulcan.lancedb.create_table: {}", error))?;
4056
4057 let upsert_binding = binding.clone();
4058 let vector_upsert_fn = lua
4059 .create_function(move |lua, input: LuaValue| {
4060 let input_table = require_table_arg(input, "lancedb.vector_upsert", "input")?;
4061 let mut input_json = lua_value_to_json(&LuaValue::Table(input_table))
4062 .map_err(mlua::Error::runtime)?;
4063 let input_object = input_json.as_object_mut().ok_or_else(|| {
4064 mlua::Error::runtime("lancedb.vector_upsert input must be an object")
4065 })?;
4066
4067 let payload_value = if let Some(rows) = input_object.remove("rows") {
4068 input_object
4069 .entry("input_format".to_string())
4070 .or_insert_with(|| Value::String("json".to_string()));
4071 rows
4072 } else if let Some(data) = input_object.remove("data") {
4073 data
4074 } else {
4075 return Err(mlua::Error::runtime(
4076 "lancedb.vector_upsert requires rows or data",
4077 ));
4078 };
4079
4080 let payload_bytes = match payload_value {
4081 Value::String(text) => {
4082 if !input_object.contains_key("input_format") {
4083 input_object.insert(
4084 "input_format".to_string(),
4085 Value::String("arrow_ipc".to_string()),
4086 );
4087 }
4088 text.into_bytes()
4089 }
4090 Value::Array(_) | Value::Object(_) => {
4091 if !input_object.contains_key("input_format") {
4092 input_object.insert(
4093 "input_format".to_string(),
4094 Value::String("json".to_string()),
4095 );
4096 }
4097 serde_json::to_vec(&payload_value).map_err(|error| {
4098 mlua::Error::runtime(format!(
4099 "failed to encode lancedb upsert payload: {}",
4100 error
4101 ))
4102 })?
4103 }
4104 _ => {
4105 return Err(mlua::Error::runtime(
4106 "lancedb.vector_upsert payload must be string",
4107 ));
4108 }
4109 };
4110
4111 let result = upsert_binding
4112 .vector_upsert_json(&input_json, &payload_bytes)
4113 .map_err(mlua::Error::runtime)?;
4114 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4115 })
4116 .map_err(|error| {
4117 format!("Failed to create vulcan.lancedb.vector_upsert: {}", error)
4118 })?;
4119 lancedb_table
4120 .set("vector_upsert", vector_upsert_fn)
4121 .map_err(|error| {
4122 format!("Failed to set vulcan.lancedb.vector_upsert: {}", error)
4123 })?;
4124
4125 let search_binding = binding.clone();
4126 let vector_search_fn = lua
4127 .create_function(move |lua, input: LuaValue| {
4128 let input_table = require_table_arg(input, "lancedb.vector_search", "input")?;
4129 let mut input_json = lua_value_to_json(&LuaValue::Table(input_table))
4130 .map_err(mlua::Error::runtime)?;
4131 let input_object = input_json.as_object_mut().ok_or_else(|| {
4132 mlua::Error::runtime("lancedb.vector_search input must be an object")
4133 })?;
4134 input_object
4135 .entry("output_format".to_string())
4136 .or_insert_with(|| Value::String("json".to_string()));
4137
4138 let (meta, raw_bytes) = search_binding
4139 .vector_search_json(&input_json)
4140 .map_err(mlua::Error::runtime)?;
4141 let result_table =
4142 json_to_lua_table_inner(lua, &meta).map_err(mlua::Error::external)?;
4143
4144 if meta
4145 .get("format")
4146 .and_then(Value::as_str)
4147 .map(|value| value == "json")
4148 .unwrap_or(false)
4149 {
4150 let rows_json: Value =
4151 serde_json::from_slice(&raw_bytes).map_err(|error| {
4152 mlua::Error::runtime(format!(
4153 "failed to parse LanceDB JSON rows: {}",
4154 error
4155 ))
4156 })?;
4157 result_table
4158 .set(
4159 "data_json",
4160 json_value_to_lua(lua, &rows_json)
4161 .map_err(mlua::Error::external)?,
4162 )
4163 .map_err(mlua::Error::external)?;
4164 } else {
4165 result_table
4166 .set(
4167 "data",
4168 LuaValue::String(
4169 lua.create_string(&raw_bytes)
4170 .map_err(mlua::Error::external)?,
4171 ),
4172 )
4173 .map_err(mlua::Error::external)?;
4174 }
4175 Ok(LuaValue::Table(result_table))
4176 })
4177 .map_err(|error| {
4178 format!("Failed to create vulcan.lancedb.vector_search: {}", error)
4179 })?;
4180 lancedb_table
4181 .set("vector_search", vector_search_fn)
4182 .map_err(|error| {
4183 format!("Failed to set vulcan.lancedb.vector_search: {}", error)
4184 })?;
4185
4186 let delete_binding = binding.clone();
4187 let delete_fn = lua
4188 .create_function(move |lua, input: LuaValue| {
4189 let input_table = require_table_arg(input, "lancedb.delete", "input")?;
4190 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4191 .map_err(mlua::Error::runtime)?;
4192 let result = delete_binding
4193 .delete_json(&input_json)
4194 .map_err(mlua::Error::runtime)?;
4195 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4196 })
4197 .map_err(|error| format!("Failed to create vulcan.lancedb.delete: {}", error))?;
4198 lancedb_table
4199 .set("delete", delete_fn)
4200 .map_err(|error| format!("Failed to set vulcan.lancedb.delete: {}", error))?;
4201
4202 let drop_binding = binding;
4203 let drop_table_fn = lua
4204 .create_function(move |lua, input: LuaValue| {
4205 let input_table = require_table_arg(input, "lancedb.drop_table", "input")?;
4206 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4207 .map_err(mlua::Error::runtime)?;
4208 let result = drop_binding
4209 .drop_table_json(&input_json)
4210 .map_err(mlua::Error::runtime)?;
4211 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4212 })
4213 .map_err(|error| {
4214 format!("Failed to create vulcan.lancedb.drop_table: {}", error)
4215 })?;
4216 lancedb_table
4217 .set("drop_table", drop_table_fn)
4218 .map_err(|error| format!("Failed to set vulcan.lancedb.drop_table: {}", error))?;
4219 } else {
4220 let disabled_status = disabled_skill_status_json(current_skill_name);
4221 lancedb_table
4222 .set("enabled", false)
4223 .map_err(|error| format!("Failed to set vulcan.lancedb.enabled: {}", error))?;
4224 let status_value = disabled_status.clone();
4225 let status_fn = lua
4226 .create_function(move |lua, ()| {
4227 json_value_to_lua(lua, &status_value).map_err(mlua::Error::external)
4228 })
4229 .map_err(|error| {
4230 format!("Failed to create disabled vulcan.lancedb.status: {}", error)
4231 })?;
4232 lancedb_table
4233 .set("status", status_fn)
4234 .map_err(|error| format!("Failed to set vulcan.lancedb.status: {}", error))?;
4235 let info_value = disabled_status.clone();
4236 let info_fn = lua
4237 .create_function(move |lua, ()| {
4238 json_value_to_lua(lua, &info_value).map_err(mlua::Error::external)
4239 })
4240 .map_err(|error| {
4241 format!("Failed to create disabled vulcan.lancedb.info: {}", error)
4242 })?;
4243 lancedb_table.set("info", info_fn).map_err(|error| {
4244 format!("Failed to set disabled vulcan.lancedb.info: {}", error)
4245 })?;
4246 let disabled_error = "current skill has not enabled lancedb".to_string();
4247 for method_name in [
4248 "create_table",
4249 "vector_upsert",
4250 "vector_search",
4251 "delete",
4252 "drop_table",
4253 ] {
4254 let error_text = disabled_error.clone();
4255 let fn_value = lua
4256 .create_function(move |_, _: MultiValue| {
4257 Err::<LuaValue, _>(mlua::Error::runtime(error_text.clone()))
4258 })
4259 .map_err(|error| {
4260 format!("Failed to create disabled vulcan.lancedb proxy: {}", error)
4261 })?;
4262 lancedb_table.set(method_name, fn_value).map_err(|error| {
4263 format!("Failed to set disabled method {}: {}", method_name, error)
4264 })?;
4265 }
4266 }
4267
4268 vulcan
4269 .set("lancedb", lancedb_table)
4270 .map_err(|error| format!("Failed to set vulcan.lancedb: {}", error))?;
4271 Ok(())
4272 }
4273
4274 fn populate_vulcan_sqlite_context(
4277 lua: &Lua,
4278 binding: Option<Arc<SqliteSkillBinding>>,
4279 current_skill_name: Option<&str>,
4280 ) -> Result<(), String> {
4281 let vulcan: Table = lua
4282 .globals()
4283 .get("vulcan")
4284 .map_err(|error| format!("Failed to get vulcan module: {}", error))?;
4285
4286 let sqlite_table = lua
4287 .create_table()
4288 .map_err(|error| format!("Failed to create vulcan.sqlite table: {}", error))?;
4289
4290 let current_skill = current_skill_name.unwrap_or("");
4291 vulcan
4292 .set("__sqlite_skill_name", current_skill)
4293 .map_err(|error| format!("Failed to set vulcan.__sqlite_skill_name: {}", error))?;
4294
4295 if let Some(binding) = binding {
4296 sqlite_table
4297 .set("enabled", true)
4298 .map_err(|error| format!("Failed to set vulcan.sqlite.enabled: {}", error))?;
4299
4300 let info_binding = binding.clone();
4301 let info_fn = lua
4302 .create_function(move |lua, ()| {
4303 json_value_to_lua(lua, &info_binding.info_json()).map_err(mlua::Error::external)
4304 })
4305 .map_err(|error| format!("Failed to create vulcan.sqlite.info: {}", error))?;
4306 sqlite_table
4307 .set("info", info_fn)
4308 .map_err(|error| format!("Failed to set vulcan.sqlite.info: {}", error))?;
4309
4310 let status_binding = binding.clone();
4311 let status_fn = lua
4312 .create_function(move |lua, ()| {
4313 json_value_to_lua(lua, &status_binding.status_json())
4314 .map_err(mlua::Error::external)
4315 })
4316 .map_err(|error| format!("Failed to create vulcan.sqlite.status: {}", error))?;
4317 sqlite_table
4318 .set("status", status_fn)
4319 .map_err(|error| format!("Failed to set vulcan.sqlite.status: {}", error))?;
4320
4321 let tokenize_binding = binding.clone();
4322 let tokenize_fn = lua
4323 .create_function(move |lua, input: LuaValue| {
4324 let input_table = require_table_arg(input, "sqlite.tokenize_text", "input")?;
4325 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4326 .map_err(mlua::Error::runtime)?;
4327 let result = tokenize_binding
4328 .tokenize_text_json(&input_json)
4329 .map_err(mlua::Error::runtime)?;
4330 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4331 })
4332 .map_err(|error| {
4333 format!("Failed to create vulcan.sqlite.tokenize_text: {}", error)
4334 })?;
4335 sqlite_table
4336 .set("tokenize_text", tokenize_fn)
4337 .map_err(|error| format!("Failed to set vulcan.sqlite.tokenize_text: {}", error))?;
4338
4339 let execute_script_binding = binding.clone();
4340 let execute_script_fn = lua
4341 .create_function(move |lua, input: LuaValue| {
4342 let input_table = require_table_arg(input, "sqlite.execute_script", "input")?;
4343 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4344 .map_err(mlua::Error::runtime)?;
4345 let result = execute_script_binding
4346 .execute_script(&input_json)
4347 .map_err(mlua::Error::runtime)?;
4348 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4349 })
4350 .map_err(|error| {
4351 format!("Failed to create vulcan.sqlite.execute_script: {}", error)
4352 })?;
4353 sqlite_table
4354 .set("execute_script", execute_script_fn)
4355 .map_err(|error| {
4356 format!("Failed to set vulcan.sqlite.execute_script: {}", error)
4357 })?;
4358
4359 let execute_batch_binding = binding.clone();
4360 let execute_batch_fn = lua
4361 .create_function(move |lua, input: LuaValue| {
4362 let input_table = require_table_arg(input, "sqlite.execute_batch", "input")?;
4363 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4364 .map_err(mlua::Error::runtime)?;
4365 let result = execute_batch_binding
4366 .execute_batch(&input_json)
4367 .map_err(mlua::Error::runtime)?;
4368 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4369 })
4370 .map_err(|error| {
4371 format!("Failed to create vulcan.sqlite.execute_batch: {}", error)
4372 })?;
4373 sqlite_table
4374 .set("execute_batch", execute_batch_fn)
4375 .map_err(|error| format!("Failed to set vulcan.sqlite.execute_batch: {}", error))?;
4376
4377 let query_json_binding = binding.clone();
4378 let query_json_fn = lua
4379 .create_function(move |lua, input: LuaValue| {
4380 let input_table = require_table_arg(input, "sqlite.query_json", "input")?;
4381 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4382 .map_err(mlua::Error::runtime)?;
4383 let result = query_json_binding
4384 .query_json(&input_json)
4385 .map_err(mlua::Error::runtime)?;
4386 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4387 })
4388 .map_err(|error| format!("Failed to create vulcan.sqlite.query_json: {}", error))?;
4389 sqlite_table
4390 .set("query_json", query_json_fn)
4391 .map_err(|error| format!("Failed to set vulcan.sqlite.query_json: {}", error))?;
4392
4393 let query_stream_binding = binding.clone();
4394 let query_stream_fn = lua
4395 .create_function(move |lua, input: LuaValue| {
4396 let input_table = require_table_arg(input, "sqlite.query_stream", "input")?;
4397 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4398 .map_err(mlua::Error::runtime)?;
4399 let result = query_stream_binding
4400 .query_stream(&input_json)
4401 .map_err(mlua::Error::runtime)?;
4402 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4403 })
4404 .map_err(|error| {
4405 format!("Failed to create vulcan.sqlite.query_stream: {}", error)
4406 })?;
4407 sqlite_table
4408 .set("query_stream", query_stream_fn)
4409 .map_err(|error| format!("Failed to set vulcan.sqlite.query_stream: {}", error))?;
4410
4411 let query_stream_wait_metrics_binding = binding.clone();
4412 let query_stream_wait_metrics_fn = lua
4413 .create_function(move |lua, input: LuaValue| {
4414 let input_table =
4415 require_table_arg(input, "sqlite.query_stream_wait_metrics", "input")?;
4416 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4417 .map_err(mlua::Error::runtime)?;
4418 let result = query_stream_wait_metrics_binding
4419 .query_stream_wait_metrics(&input_json)
4420 .map_err(mlua::Error::runtime)?;
4421 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4422 })
4423 .map_err(|error| {
4424 format!(
4425 "Failed to create vulcan.sqlite.query_stream_wait_metrics: {}",
4426 error
4427 )
4428 })?;
4429 sqlite_table
4430 .set("query_stream_wait_metrics", query_stream_wait_metrics_fn)
4431 .map_err(|error| {
4432 format!(
4433 "Failed to set vulcan.sqlite.query_stream_wait_metrics: {}",
4434 error
4435 )
4436 })?;
4437
4438 let query_stream_chunk_binding = binding.clone();
4439 let query_stream_chunk_fn = lua
4440 .create_function(move |lua, input: LuaValue| {
4441 let input_table =
4442 require_table_arg(input, "sqlite.query_stream_chunk", "input")?;
4443 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4444 .map_err(mlua::Error::runtime)?;
4445 let result = query_stream_chunk_binding
4446 .query_stream_chunk(&input_json)
4447 .map_err(mlua::Error::runtime)?;
4448 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4449 })
4450 .map_err(|error| {
4451 format!(
4452 "Failed to create vulcan.sqlite.query_stream_chunk: {}",
4453 error
4454 )
4455 })?;
4456 sqlite_table
4457 .set("query_stream_chunk", query_stream_chunk_fn)
4458 .map_err(|error| {
4459 format!("Failed to set vulcan.sqlite.query_stream_chunk: {}", error)
4460 })?;
4461
4462 let query_stream_close_binding = binding.clone();
4463 let query_stream_close_fn = lua
4464 .create_function(move |lua, input: LuaValue| {
4465 let input_table =
4466 require_table_arg(input, "sqlite.query_stream_close", "input")?;
4467 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4468 .map_err(mlua::Error::runtime)?;
4469 let result = query_stream_close_binding
4470 .query_stream_close(&input_json)
4471 .map_err(mlua::Error::runtime)?;
4472 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4473 })
4474 .map_err(|error| {
4475 format!(
4476 "Failed to create vulcan.sqlite.query_stream_close: {}",
4477 error
4478 )
4479 })?;
4480 sqlite_table
4481 .set("query_stream_close", query_stream_close_fn)
4482 .map_err(|error| {
4483 format!("Failed to set vulcan.sqlite.query_stream_close: {}", error)
4484 })?;
4485
4486 let upsert_word_binding = binding.clone();
4487 let upsert_word_fn = lua
4488 .create_function(move |lua, input: LuaValue| {
4489 let input_table =
4490 require_table_arg(input, "sqlite.upsert_custom_word", "input")?;
4491 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4492 .map_err(mlua::Error::runtime)?;
4493 let result = upsert_word_binding
4494 .upsert_custom_word_json(&input_json)
4495 .map_err(mlua::Error::runtime)?;
4496 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4497 })
4498 .map_err(|error| {
4499 format!(
4500 "Failed to create vulcan.sqlite.upsert_custom_word: {}",
4501 error
4502 )
4503 })?;
4504 sqlite_table
4505 .set("upsert_custom_word", upsert_word_fn)
4506 .map_err(|error| {
4507 format!("Failed to set vulcan.sqlite.upsert_custom_word: {}", error)
4508 })?;
4509
4510 let remove_word_binding = binding.clone();
4511 let remove_word_fn = lua
4512 .create_function(move |lua, input: LuaValue| {
4513 let input_table =
4514 require_table_arg(input, "sqlite.remove_custom_word", "input")?;
4515 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4516 .map_err(mlua::Error::runtime)?;
4517 let result = remove_word_binding
4518 .remove_custom_word_json(&input_json)
4519 .map_err(mlua::Error::runtime)?;
4520 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4521 })
4522 .map_err(|error| {
4523 format!(
4524 "Failed to create vulcan.sqlite.remove_custom_word: {}",
4525 error
4526 )
4527 })?;
4528 sqlite_table
4529 .set("remove_custom_word", remove_word_fn)
4530 .map_err(|error| {
4531 format!("Failed to set vulcan.sqlite.remove_custom_word: {}", error)
4532 })?;
4533
4534 let list_words_binding = binding.clone();
4535 let list_words_fn = lua
4536 .create_function(move |lua, ()| {
4537 let result = list_words_binding
4538 .list_custom_words_json()
4539 .map_err(mlua::Error::runtime)?;
4540 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4541 })
4542 .map_err(|error| {
4543 format!(
4544 "Failed to create vulcan.sqlite.list_custom_words: {}",
4545 error
4546 )
4547 })?;
4548 sqlite_table
4549 .set("list_custom_words", list_words_fn)
4550 .map_err(|error| {
4551 format!("Failed to set vulcan.sqlite.list_custom_words: {}", error)
4552 })?;
4553
4554 let ensure_index_binding = binding.clone();
4555 let ensure_index_fn = lua
4556 .create_function(move |lua, input: LuaValue| {
4557 let input_table = require_table_arg(input, "sqlite.ensure_fts_index", "input")?;
4558 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4559 .map_err(mlua::Error::runtime)?;
4560 let result = ensure_index_binding
4561 .ensure_fts_index_json(&input_json)
4562 .map_err(mlua::Error::runtime)?;
4563 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4564 })
4565 .map_err(|error| {
4566 format!("Failed to create vulcan.sqlite.ensure_fts_index: {}", error)
4567 })?;
4568 sqlite_table
4569 .set("ensure_fts_index", ensure_index_fn)
4570 .map_err(|error| {
4571 format!("Failed to set vulcan.sqlite.ensure_fts_index: {}", error)
4572 })?;
4573
4574 let rebuild_index_binding = binding.clone();
4575 let rebuild_index_fn = lua
4576 .create_function(move |lua, input: LuaValue| {
4577 let input_table =
4578 require_table_arg(input, "sqlite.rebuild_fts_index", "input")?;
4579 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4580 .map_err(mlua::Error::runtime)?;
4581 let result = rebuild_index_binding
4582 .rebuild_fts_index_json(&input_json)
4583 .map_err(mlua::Error::runtime)?;
4584 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4585 })
4586 .map_err(|error| {
4587 format!(
4588 "Failed to create vulcan.sqlite.rebuild_fts_index: {}",
4589 error
4590 )
4591 })?;
4592 sqlite_table
4593 .set("rebuild_fts_index", rebuild_index_fn)
4594 .map_err(|error| {
4595 format!("Failed to set vulcan.sqlite.rebuild_fts_index: {}", error)
4596 })?;
4597
4598 let upsert_doc_binding = binding.clone();
4599 let upsert_doc_fn = lua
4600 .create_function(move |lua, input: LuaValue| {
4601 let input_table =
4602 require_table_arg(input, "sqlite.upsert_fts_document", "input")?;
4603 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4604 .map_err(mlua::Error::runtime)?;
4605 let result = upsert_doc_binding
4606 .upsert_fts_document_json(&input_json)
4607 .map_err(mlua::Error::runtime)?;
4608 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4609 })
4610 .map_err(|error| {
4611 format!(
4612 "Failed to create vulcan.sqlite.upsert_fts_document: {}",
4613 error
4614 )
4615 })?;
4616 sqlite_table
4617 .set("upsert_fts_document", upsert_doc_fn)
4618 .map_err(|error| {
4619 format!("Failed to set vulcan.sqlite.upsert_fts_document: {}", error)
4620 })?;
4621
4622 let delete_doc_binding = binding.clone();
4623 let delete_doc_fn = lua
4624 .create_function(move |lua, input: LuaValue| {
4625 let input_table =
4626 require_table_arg(input, "sqlite.delete_fts_document", "input")?;
4627 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4628 .map_err(mlua::Error::runtime)?;
4629 let result = delete_doc_binding
4630 .delete_fts_document_json(&input_json)
4631 .map_err(mlua::Error::runtime)?;
4632 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4633 })
4634 .map_err(|error| {
4635 format!(
4636 "Failed to create vulcan.sqlite.delete_fts_document: {}",
4637 error
4638 )
4639 })?;
4640 sqlite_table
4641 .set("delete_fts_document", delete_doc_fn)
4642 .map_err(|error| {
4643 format!("Failed to set vulcan.sqlite.delete_fts_document: {}", error)
4644 })?;
4645
4646 let search_binding = binding;
4647 let search_fn = lua
4648 .create_function(move |lua, input: LuaValue| {
4649 let input_table = require_table_arg(input, "sqlite.search_fts", "input")?;
4650 let input_json = lua_value_to_json(&LuaValue::Table(input_table))
4651 .map_err(mlua::Error::runtime)?;
4652 let result = search_binding
4653 .search_fts_json(&input_json)
4654 .map_err(mlua::Error::runtime)?;
4655 json_value_to_lua(lua, &result).map_err(mlua::Error::external)
4656 })
4657 .map_err(|error| format!("Failed to create vulcan.sqlite.search_fts: {}", error))?;
4658 sqlite_table
4659 .set("search_fts", search_fn)
4660 .map_err(|error| format!("Failed to set vulcan.sqlite.search_fts: {}", error))?;
4661 } else {
4662 let disabled_status = disabled_sqlite_skill_status_json(current_skill_name);
4663 sqlite_table
4664 .set("enabled", false)
4665 .map_err(|error| format!("Failed to set vulcan.sqlite.enabled: {}", error))?;
4666 let status_value = disabled_status.clone();
4667 let status_fn = lua
4668 .create_function(move |lua, ()| {
4669 json_value_to_lua(lua, &status_value).map_err(mlua::Error::external)
4670 })
4671 .map_err(|error| {
4672 format!("Failed to create disabled vulcan.sqlite.status: {}", error)
4673 })?;
4674 sqlite_table
4675 .set("status", status_fn)
4676 .map_err(|error| format!("Failed to set vulcan.sqlite.status: {}", error))?;
4677 let info_value = disabled_status.clone();
4678 let info_fn = lua
4679 .create_function(move |lua, ()| {
4680 json_value_to_lua(lua, &info_value).map_err(mlua::Error::external)
4681 })
4682 .map_err(|error| {
4683 format!("Failed to create disabled vulcan.sqlite.info: {}", error)
4684 })?;
4685 sqlite_table
4686 .set("info", info_fn)
4687 .map_err(|error| format!("Failed to set disabled vulcan.sqlite.info: {}", error))?;
4688 let disabled_error = "current skill has not enabled sqlite".to_string();
4689 for method_name in [
4690 "execute_script",
4691 "execute_batch",
4692 "query_json",
4693 "query_stream",
4694 "query_stream_wait_metrics",
4695 "query_stream_chunk",
4696 "query_stream_close",
4697 "tokenize_text",
4698 "upsert_custom_word",
4699 "remove_custom_word",
4700 "list_custom_words",
4701 "ensure_fts_index",
4702 "rebuild_fts_index",
4703 "upsert_fts_document",
4704 "delete_fts_document",
4705 "search_fts",
4706 ] {
4707 let error_text = disabled_error.clone();
4708 let fn_value = lua
4709 .create_function(move |_, _: MultiValue| {
4710 Err::<LuaValue, _>(mlua::Error::runtime(error_text.clone()))
4711 })
4712 .map_err(|error| {
4713 format!("Failed to create disabled vulcan.sqlite proxy: {}", error)
4714 })?;
4715 sqlite_table.set(method_name, fn_value).map_err(|error| {
4716 format!("Failed to set disabled method {}: {}", method_name, error)
4717 })?;
4718 }
4719 }
4720
4721 vulcan
4722 .set("sqlite", sqlite_table)
4723 .map_err(|error| format!("Failed to set vulcan.sqlite: {}", error))?;
4724 Ok(())
4725 }
4726
4727 pub fn call_skill(
4730 &self,
4731 tool_name: &str,
4732 args: &Value,
4733 invocation_context: Option<&LuaInvocationContext>,
4734 ) -> Result<RuntimeInvocationResult, String> {
4735 let resolved_target = self
4736 .entry_registry
4737 .get(tool_name)
4738 .ok_or_else(|| format!("Lua skill '{}' not found", tool_name))?;
4739 let skill = self
4740 .skills
4741 .get(&resolved_target.skill_storage_key)
4742 .ok_or_else(|| format!("Lua skill '{}' not found", tool_name))?;
4743 let tool = skill
4744 .meta
4745 .find_tool_by_local_name(&resolved_target.local_name)
4746 .ok_or_else(|| format!("Lua skill '{}' not found", tool_name))?;
4747 let display_tool_name = resolved_target.canonical_name.clone();
4748
4749 let module_name = tool.lua_module.clone();
4750 let func_name = format!("__skill_{}", module_name);
4751
4752 let mut lease = self.acquire_vm()?;
4753 let scope_guard = LuaVmRequestScopeGuard::new(&mut lease, self.host_options.as_ref())?;
4754 let lua = scope_guard.lua();
4755
4756 if skill.meta.debug {
4757 Self::compile_skill_into_lua(lua, skill, tool, true)?;
4758 }
4759
4760 Self::populate_vulcan_request_context(lua, invocation_context)?;
4761 populate_vulcan_internal_execution_context(
4762 lua,
4763 &VulcanInternalExecutionContext {
4764 tool_name: Some(display_tool_name.clone()),
4765 skill_name: Some(skill.meta.effective_skill_id().to_string()),
4766 luaexec_active: false,
4767 luaexec_caller_tool_name: None,
4768 },
4769 )?;
4770 let entry_path = tool_entry_path(&skill.dir, tool);
4771 populate_vulcan_file_context(lua, Some(&skill.dir), Some(&entry_path))?;
4772 populate_vulcan_dependency_context(
4773 lua,
4774 self.host_options.as_ref(),
4775 Some(&skill.dir),
4776 Some(skill.meta.effective_skill_id()),
4777 )?;
4778 Self::populate_vulcan_lancedb_context(
4779 lua,
4780 skill.lancedb_binding.clone(),
4781 Some(skill.meta.effective_skill_id()),
4782 )?;
4783 Self::populate_vulcan_sqlite_context(
4784 lua,
4785 skill.sqlite_binding.clone(),
4786 Some(skill.meta.effective_skill_id()),
4787 )?;
4788
4789 let handler: Function = lua
4790 .globals()
4791 .get(func_name.as_str())
4792 .map_err(|e| format!("Skill function '{}' not found: {}", module_name, e))?;
4793
4794 let args_table = json_to_lua_table(lua, args)?;
4796
4797 let call_result = (|| {
4798 let result: MultiValue = handler.call(args_table).map_err(|e| {
4800 let msg = format!("Lua skill '{}' error: {}", display_tool_name, e);
4801 log_error(format!("[LuaSkill:error] {}", msg));
4802 msg
4803 })?;
4804
4805 parse_tool_call_output(result, &display_tool_name).map_err(|e| {
4806 log_error(format!("[LuaSkill:error] {}", e));
4807 e
4808 })
4809 })();
4810 let cleanup_result = scope_guard.finish();
4811 match (call_result, cleanup_result) {
4812 (Ok(result), Ok(())) => Ok(result),
4813 (Ok(_), Err(cleanup_error)) => Err(cleanup_error),
4814 (Err(call_error), Ok(())) => Err(call_error),
4815 (Err(call_error), Err(cleanup_error)) => Err(format!(
4816 "{}; pooled Lua VM cleanup failed: {}",
4817 call_error, cleanup_error
4818 )),
4819 }
4820 }
4821
4822 pub fn run_lua(
4824 &self,
4825 code: &str,
4826 args: &Value,
4827 invocation_context: Option<&LuaInvocationContext>,
4828 ) -> Result<Value, String> {
4829 let mut lease = self.acquire_vm()?;
4830 let scope_guard = LuaVmRequestScopeGuard::new(&mut lease, self.host_options.as_ref())?;
4831 let lua = scope_guard.lua();
4832 Self::populate_vulcan_request_context(lua, invocation_context)?;
4833 populate_vulcan_internal_execution_context(
4834 lua,
4835 &VulcanInternalExecutionContext::default(),
4836 )?;
4837 populate_vulcan_file_context(lua, None, None)?;
4838 populate_vulcan_dependency_context(lua, self.host_options.as_ref(), None, None)?;
4839 Self::populate_vulcan_lancedb_context(lua, None, None)?;
4840 Self::populate_vulcan_sqlite_context(lua, None, None)?;
4841
4842 let args_table = json_to_lua_table(lua, args)?;
4844 lua.globals()
4845 .set("__runlua_args", args_table)
4846 .map_err(|e| format!("Failed to set args: {}", e))?;
4847
4848 let wrapper = format!(
4849 "return (function()\n local args = __runlua_args\n {}\nend)()",
4850 code
4851 );
4852
4853 let run_result = (|| {
4854 let result = lua.load(&wrapper).eval::<LuaValue>().map_err(|e| {
4855 let msg = format!("Lua run_lua error: {}", e);
4856 log_error(format!("[LuaSkill:error] {}", msg));
4857 msg
4858 })?;
4859
4860 lua_value_to_json(&result)
4861 })();
4862 let cleanup_result = scope_guard.finish();
4863 match (run_result, cleanup_result) {
4864 (Ok(result), Ok(())) => Ok(result),
4865 (Ok(_), Err(cleanup_error)) => Err(cleanup_error),
4866 (Err(run_error), Ok(())) => Err(run_error),
4867 (Err(run_error), Err(cleanup_error)) => Err(format!(
4868 "{}; pooled Lua VM cleanup failed: {}",
4869 run_error, cleanup_error
4870 )),
4871 }
4872 }
4873
4874 fn acquire_runlua_vm(
4877 runlua_pool: Arc<LuaVmPool>,
4878 skills: Arc<HashMap<String, LoadedSkill>>,
4879 entry_registry: Arc<BTreeMap<String, ResolvedEntryTarget>>,
4880 host_options: Arc<LuaRuntimeHostOptions>,
4881 skill_config_store: Arc<SkillConfigStore>,
4882 lancedb_host: Option<Arc<LanceDbSkillHost>>,
4883 sqlite_host: Option<Arc<SqliteSkillHost>>,
4884 ) -> Result<LuaVmLease, String> {
4885 runlua_pool.acquire(move || {
4886 Self::create_runlua_vm(
4887 skills.as_ref(),
4888 entry_registry.as_ref(),
4889 host_options.clone(),
4890 skill_config_store.clone(),
4891 lancedb_host.clone(),
4892 sqlite_host.clone(),
4893 )
4894 })
4895 }
4896
4897 fn execute_runlua_request_inline_with_runtime(
4900 request: &RunLuaExecRequest,
4901 runlua_pool: Arc<LuaVmPool>,
4902 skills: Arc<HashMap<String, LoadedSkill>>,
4903 entry_registry: Arc<BTreeMap<String, ResolvedEntryTarget>>,
4904 host_options: Arc<LuaRuntimeHostOptions>,
4905 skill_config_store: Arc<SkillConfigStore>,
4906 lancedb_host: Option<Arc<LanceDbSkillHost>>,
4907 sqlite_host: Option<Arc<SqliteSkillHost>>,
4908 ) -> Result<String, String> {
4909 if request.timeout_ms == 0 {
4910 return Err("luaexec timeout_ms must be greater than 0".to_string());
4911 }
4912 let (resolved_code, entry_file) = Self::resolve_runlua_source(request)?;
4913 let mut lease = Self::acquire_runlua_vm(
4914 runlua_pool,
4915 skills,
4916 entry_registry,
4917 host_options.clone(),
4918 skill_config_store,
4919 lancedb_host,
4920 sqlite_host,
4921 )?;
4922 let scope_guard = LuaVmRequestScopeGuard::new(&mut lease, host_options.as_ref())?;
4923 let lua = scope_guard.lua();
4924 let simulated_request_context = build_luaexec_call_request_context();
4925 let simulated_invocation_context = LuaInvocationContext::new(
4926 Some(simulated_request_context),
4927 Value::Object(serde_json::Map::new()),
4928 Value::Object(serde_json::Map::new()),
4929 );
4930 Self::populate_vulcan_request_context(lua, Some(&simulated_invocation_context))?;
4931 populate_vulcan_internal_execution_context(
4932 lua,
4933 &VulcanInternalExecutionContext {
4934 tool_name: None,
4935 skill_name: None,
4936 luaexec_active: true,
4937 luaexec_caller_tool_name: request.caller_tool_name.clone(),
4938 },
4939 )?;
4940 populate_vulcan_file_context(lua, None, entry_file.as_deref())?;
4941 Self::populate_vulcan_lancedb_context(lua, None, None)?;
4942 Self::populate_vulcan_sqlite_context(lua, None, None)?;
4943
4944 let captured_output: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
4945 Self::configure_runlua_execution_environment(lua, captured_output.clone())?;
4946
4947 let args_table = json_to_lua_table(lua, &request.args)?;
4948 lua.globals()
4949 .set("__runlua_args", args_table)
4950 .map_err(|error| format!("Failed to set runlua args: {}", error))?;
4951
4952 let wrapper = format!(
4953 "return (function()\n local args = __runlua_args\n return table.pack((function()\n{}\nend)())\nend)()",
4954 resolved_code
4955 );
4956
4957 Self::install_runlua_timeout_guard(lua, request.timeout_ms)
4958 .map_err(|error| error.to_string())?;
4959 let execution_result = Self::execute_runlua_wrapper(lua, &wrapper, entry_file.as_deref());
4960 Self::remove_runlua_timeout_guard(lua);
4961 let printed_output = captured_output
4962 .lock()
4963 .map_err(|_| "Failed to lock runlua output capture".to_string())?
4964 .clone();
4965
4966 let render_result = match execution_result {
4967 Ok(returned_values) => {
4968 let rendered_values = Self::collect_runlua_return_values(&returned_values)?;
4969 Ok(Self::render_runlua_success_markdown(
4970 request,
4971 &printed_output,
4972 &rendered_values,
4973 ))
4974 }
4975 Err(error) => Ok(Self::render_runlua_error_markdown(
4976 request,
4977 &printed_output,
4978 error.to_string().as_str(),
4979 )),
4980 };
4981 let cleanup_result = scope_guard.finish();
4982 match (render_result, cleanup_result) {
4983 (Ok(rendered), Ok(())) => Ok(rendered),
4984 (Ok(_), Err(cleanup_error)) => Err(cleanup_error),
4985 (Err(render_error), Ok(())) => Err(render_error),
4986 (Err(render_error), Err(cleanup_error)) => Err(format!(
4987 "{}; pooled runlua VM cleanup failed: {}",
4988 render_error, cleanup_error
4989 )),
4990 }
4991 }
4992
4993 fn execute_runlua_request_inline(&self, request: &RunLuaExecRequest) -> Result<String, String> {
4996 Self::execute_runlua_request_inline_with_runtime(
4997 request,
4998 self.runlua_pool.clone(),
4999 Arc::new(self.skills.clone()),
5000 Arc::new(self.entry_registry.clone()),
5001 self.host_options.clone(),
5002 self.skill_config_store.clone(),
5003 self.lancedb_host.clone(),
5004 self.sqlite_host.clone(),
5005 )
5006 }
5007
5008 fn resolve_runlua_source(
5011 request: &RunLuaExecRequest,
5012 ) -> Result<(String, Option<PathBuf>), String> {
5013 let inline_code = request
5014 .code
5015 .as_ref()
5016 .map(|value| value.trim())
5017 .filter(|value| !value.is_empty())
5018 .map(|value| value.to_string());
5019 let file_path = request
5020 .file
5021 .as_ref()
5022 .map(|value| value.trim())
5023 .filter(|value| !value.is_empty())
5024 .map(|value| value.to_string());
5025
5026 match (inline_code, file_path) {
5027 (Some(_), Some(_)) => {
5028 Err("luaexec accepts either code or file, but not both".to_string())
5029 }
5030 (None, None) => Err("luaexec requires code or file".to_string()),
5031 (Some(code), None) => Ok((code, None)),
5032 (None, Some(file_text)) => {
5033 validate_path_text(&file_text, "luaexec", "file")
5034 .map_err(|error| error.to_string())?;
5035 let raw_file_path = PathBuf::from(&file_text);
5036 let file_path = if raw_file_path.is_absolute() {
5037 raw_file_path
5038 } else {
5039 std::env::current_dir()
5040 .map_err(|error| {
5041 format!("Failed to resolve luaexec relative file path: {}", error)
5042 })?
5043 .join(raw_file_path)
5044 };
5045 let source = std::fs::read_to_string(&file_path).map_err(|error| {
5046 format!(
5047 "Failed to read luaexec file {}: {}: {}",
5048 file_path.display(),
5049 error,
5050 error
5051 )
5052 })?;
5053 Ok((source, Some(file_path)))
5054 }
5055 }
5056 }
5057
5058 pub fn execute_runlua_request_json_inline(&self, request_json: &str) -> Result<String, String> {
5061 let request: RunLuaExecRequest = serde_json::from_str(request_json)
5062 .map_err(|error| format!("Invalid luaexec request JSON: {}", error))?;
5063 self.execute_runlua_request_inline(&request)
5064 }
5065
5066 fn execute_runlua_wrapper(
5069 lua: &Lua,
5070 wrapper: &str,
5071 entry_file: Option<&Path>,
5072 ) -> Result<Table, mlua::Error> {
5073 match entry_file.and_then(Path::parent) {
5074 Some(entry_dir) => {
5075 let _cwd_guard = runlua_cwd_guard()
5076 .lock()
5077 .map_err(|_| mlua::Error::runtime("luaexec cwd guard lock poisoned"))?;
5078 let original_dir = std::env::current_dir()
5079 .map_err(|error| mlua::Error::runtime(format!("luaexec cwd: {}", error)))?;
5080 std::env::set_current_dir(entry_dir)
5081 .map_err(|error| mlua::Error::runtime(format!("luaexec set cwd: {}", error)))?;
5082 let execution = lua.load(wrapper).eval::<Table>();
5083 let restore_result = std::env::set_current_dir(&original_dir).map_err(|error| {
5084 mlua::Error::runtime(format!("luaexec restore cwd: {}", error))
5085 });
5086 match (execution, restore_result) {
5087 (Ok(table), Ok(())) => Ok(table),
5088 (Err(error), Ok(())) => Err(error),
5089 (_, Err(error)) => Err(error),
5090 }
5091 }
5092 None => lua.load(wrapper).eval::<Table>(),
5093 }
5094 }
5095
5096 fn configure_runlua_execution_environment(
5099 lua: &Lua,
5100 captured_output: Arc<Mutex<Vec<String>>>,
5101 ) -> Result<(), String> {
5102 let runtime = get_vulcan_runtime_table(lua)?;
5103 let runtime_lua = get_vulcan_runtime_lua_table(lua)?;
5104 let cache = get_vulcan_table(lua)?
5105 .get::<Table>("cache")
5106 .map_err(|error| format!("Failed to get vulcan.cache: {}", error))?;
5107
5108 let print_capture = captured_output.clone();
5109 let print_fn = lua
5110 .create_function(move |_, args: MultiValue| {
5111 let mut parts = Vec::new();
5112 for value in args.into_iter() {
5113 parts.push(LuaEngine::render_lua_value_inline(&value));
5114 }
5115 let mut guard = print_capture
5116 .lock()
5117 .map_err(|_| mlua::Error::runtime("runlua print capture lock poisoned"))?;
5118 guard.push(parts.join("\t"));
5119 Ok(())
5120 })
5121 .map_err(|error| format!("Failed to create runlua print capture: {}", error))?;
5122 lua.globals()
5123 .set("print", print_fn)
5124 .map_err(|error| format!("Failed to override global print for runlua: {}", error))?;
5125
5126 lua.load(
5127 r#"
5128if jit and type(jit.off) == "function" then
5129 jit.off(true, true)
5130end
5131if jit and type(jit.flush) == "function" then
5132 jit.flush()
5133end
5134"#,
5135 )
5136 .exec()
5137 .map_err(|error| format!("Failed to disable JIT for runlua: {}", error))?;
5138
5139 runtime
5140 .set("log", LuaValue::Nil)
5141 .map_err(|error| format!("Failed to clear vulcan.runtime.log for runlua: {}", error))?;
5142 cache
5143 .set("put", LuaValue::Nil)
5144 .map_err(|error| format!("Failed to clear vulcan.cache.put for runlua: {}", error))?;
5145 cache
5146 .set("get", LuaValue::Nil)
5147 .map_err(|error| format!("Failed to clear vulcan.cache.get for runlua: {}", error))?;
5148 cache.set("delete", LuaValue::Nil).map_err(|error| {
5149 format!("Failed to clear vulcan.cache.delete for runlua: {}", error)
5150 })?;
5151 runtime_lua.set("exec", LuaValue::Nil).map_err(|error| {
5152 format!(
5153 "Failed to clear vulcan.runtime.lua.exec for runlua: {}",
5154 error
5155 )
5156 })?;
5157 Ok(())
5158 }
5159
5160 fn install_runlua_timeout_guard(lua: &Lua, timeout_ms: u64) -> mlua::Result<()> {
5163 let deadline = Instant::now() + Duration::from_millis(timeout_ms);
5164 let timeout_text = format!("luaexec execution timed out after {} ms", timeout_ms);
5165
5166 lua.set_hook(
5167 HookTriggers::new().every_nth_instruction(1_000),
5168 move |_, _| {
5169 if Instant::now() >= deadline {
5170 return Err(mlua::Error::runtime(timeout_text.clone()));
5171 }
5172 Ok(VmState::Continue)
5173 },
5174 )
5175 }
5176
5177 fn remove_runlua_timeout_guard(lua: &Lua) {
5180 lua.remove_hook();
5181 }
5182
5183 fn collect_runlua_return_values(
5186 result_table: &Table,
5187 ) -> Result<Vec<RunLuaRenderedValue>, String> {
5188 let value_count = result_table
5189 .get::<i64>("n")
5190 .map_err(|error| format!("Failed to read runlua return count: {}", error))?
5191 .max(0) as usize;
5192
5193 let mut rendered_values = Vec::new();
5194 if value_count == 0 {
5195 rendered_values.push(RunLuaRenderedValue {
5196 format: "json",
5197 content: "null".to_string(),
5198 });
5199 return Ok(rendered_values);
5200 }
5201
5202 for index in 1..=value_count {
5203 let value: LuaValue = result_table.raw_get(index).map_err(|error| {
5204 format!("Failed to read runlua return value {}: {}", index, error)
5205 })?;
5206 rendered_values.push(Self::render_runlua_value(&value));
5207 }
5208
5209 Ok(rendered_values)
5210 }
5211
5212 fn render_runlua_value(value: &LuaValue) -> RunLuaRenderedValue {
5215 match value {
5216 LuaValue::String(text) => RunLuaRenderedValue {
5217 format: "text",
5218 content: text
5219 .to_str()
5220 .map(|value| value.to_string())
5221 .unwrap_or_default(),
5222 },
5223 _ => match lua_value_to_json(value) {
5224 Ok(json_value) => RunLuaRenderedValue {
5225 format: "json",
5226 content: serde_json::to_string_pretty(&json_value)
5227 .unwrap_or_else(|_| "null".to_string()),
5228 },
5229 Err(_) => RunLuaRenderedValue {
5230 format: "text",
5231 content: Self::render_lua_value_inline(value),
5232 },
5233 },
5234 }
5235 }
5236
5237 fn render_lua_value_inline(value: &LuaValue) -> String {
5240 match value {
5241 LuaValue::String(text) => text
5242 .to_str()
5243 .map(|value| value.to_string())
5244 .unwrap_or_default(),
5245 LuaValue::Integer(number) => number.to_string(),
5246 LuaValue::Number(number) => number.to_string(),
5247 LuaValue::Boolean(flag) => flag.to_string(),
5248 LuaValue::Nil => "nil".to_string(),
5249 _ => format!("{:?}", value),
5250 }
5251 }
5252
5253 fn render_runlua_success_markdown(
5256 request: &RunLuaExecRequest,
5257 printed_output: &[String],
5258 rendered_values: &[RunLuaRenderedValue],
5259 ) -> String {
5260 let mut lines = vec![
5261 "# Runtime Execution Result".to_string(),
5262 "".to_string(),
5263 "## Task".to_string(),
5264 if request.task.trim().is_empty() {
5265 "Execute Lua runtime code".to_string()
5266 } else {
5267 request.task.trim().to_string()
5268 },
5269 "".to_string(),
5270 "## Status".to_string(),
5271 "SUCCESS".to_string(),
5272 ];
5273
5274 if !printed_output.is_empty() {
5275 lines.extend([
5276 "".to_string(),
5277 "## Printed Output".to_string(),
5278 "```text".to_string(),
5279 printed_output.join("\n"),
5280 "```".to_string(),
5281 ]);
5282 }
5283
5284 lines.extend(["".to_string(), "## Returned Values".to_string()]);
5285
5286 for (index, value) in rendered_values.iter().enumerate() {
5287 lines.push(format!("{}. ", index + 1));
5288 lines.push(format!("```{}", value.format));
5289 lines.push(value.content.clone());
5290 lines.push("```".to_string());
5291 if index + 1 < rendered_values.len() {
5292 lines.push("".to_string());
5293 }
5294 }
5295
5296 lines.join("\n")
5297 }
5298
5299 fn render_runlua_error_markdown(
5302 request: &RunLuaExecRequest,
5303 printed_output: &[String],
5304 error_text: &str,
5305 ) -> String {
5306 let mut lines = vec![
5307 "# Runtime Execution Error".to_string(),
5308 "".to_string(),
5309 "## Task".to_string(),
5310 if request.task.trim().is_empty() {
5311 "Execute Lua runtime code".to_string()
5312 } else {
5313 request.task.trim().to_string()
5314 },
5315 "".to_string(),
5316 "## Status".to_string(),
5317 "FAILED".to_string(),
5318 "".to_string(),
5319 "## Error".to_string(),
5320 "```text".to_string(),
5321 error_text.to_string(),
5322 "```".to_string(),
5323 ];
5324
5325 if !printed_output.is_empty() {
5326 lines.extend([
5327 "".to_string(),
5328 "## Printed Output".to_string(),
5329 "```text".to_string(),
5330 printed_output.join("\n"),
5331 "```".to_string(),
5332 ]);
5333 }
5334
5335 lines.join("\n")
5336 }
5337
5338 fn render_help_payload(
5341 &self,
5342 skill: &LoadedSkill,
5343 relative_path: &str,
5344 request_context: Option<&RuntimeRequestContext>,
5345 ) -> Result<String, String> {
5346 if !is_lua_help_file(relative_path) {
5347 return read_skill_text_file(&skill.dir, relative_path, "help");
5348 }
5349
5350 let helper_path = skill.dir.join(relative_path);
5351 let helper_source = std::fs::read_to_string(&helper_path).map_err(|error| {
5352 format!(
5353 "Failed to read help file {}: {}",
5354 helper_path.display(),
5355 error
5356 )
5357 })?;
5358 let mut lease = self.acquire_vm()?;
5359 let scope_guard = LuaVmRequestScopeGuard::new(&mut lease, self.host_options.as_ref())?;
5360 let lua = scope_guard.lua();
5361 let help_invocation_context = LuaInvocationContext::new(
5362 request_context.cloned(),
5363 Value::Object(serde_json::Map::new()),
5364 Value::Object(serde_json::Map::new()),
5365 );
5366 Self::populate_vulcan_request_context(lua, Some(&help_invocation_context))?;
5367 populate_vulcan_internal_execution_context(
5368 lua,
5369 &VulcanInternalExecutionContext {
5370 tool_name: Some("vulcan-help".to_string()),
5371 skill_name: Some(skill.meta.effective_skill_id().to_string()),
5372 luaexec_active: false,
5373 luaexec_caller_tool_name: None,
5374 },
5375 )?;
5376 populate_vulcan_file_context(lua, Some(&skill.dir), Some(&helper_path))?;
5377 populate_vulcan_dependency_context(
5378 lua,
5379 self.host_options.as_ref(),
5380 Some(&skill.dir),
5381 Some(skill.meta.effective_skill_id()),
5382 )?;
5383 Self::populate_vulcan_lancedb_context(
5384 lua,
5385 skill.lancedb_binding.clone(),
5386 Some(skill.meta.effective_skill_id()),
5387 )?;
5388 Self::populate_vulcan_sqlite_context(
5389 lua,
5390 skill.sqlite_binding.clone(),
5391 Some(skill.meta.effective_skill_id()),
5392 )?;
5393
5394 let chunk_name = format!("{}-{}", skill.meta.effective_skill_id(), relative_path);
5395 let chunk = lua.load(&helper_source).set_name(&chunk_name);
5396 let rendered_result = (|| {
5397 let exported: LuaValue = chunk
5398 .into_function()
5399 .map_err(|error| {
5400 format!(
5401 "Help compile error for {}: {}",
5402 helper_path.display(),
5403 error
5404 )
5405 })?
5406 .call(())
5407 .map_err(|error| {
5408 format!("Help init error for {}: {}", helper_path.display(), error)
5409 })?;
5410
5411 let rendered_value = match exported {
5412 LuaValue::Function(function) => function.call(()).map_err(|error| {
5413 format!(
5414 "Help runtime error for {}: {}",
5415 helper_path.display(),
5416 error
5417 )
5418 })?,
5419 other => other,
5420 };
5421
5422 match rendered_value {
5423 LuaValue::String(text) => {
5424 text.to_str()
5425 .map(|value| value.to_string())
5426 .map_err(|error| {
5427 format!(
5428 "Help {} returned invalid UTF-8 text: {}",
5429 helper_path.display(),
5430 error
5431 )
5432 })
5433 }
5434 other => Err(format!(
5435 "Help {} must return a plain string, actual_type='{}'",
5436 helper_path.display(),
5437 lua_value_type_name(&other)
5438 )),
5439 }
5440 })();
5441 let cleanup_result = scope_guard.finish();
5442 match (rendered_result, cleanup_result) {
5443 (Ok(rendered), Ok(())) => Ok(rendered),
5444 (Ok(_), Err(cleanup_error)) => Err(cleanup_error),
5445 (Err(render_error), Ok(())) => Err(render_error),
5446 (Err(render_error), Err(cleanup_error)) => Err(format!(
5447 "{}; pooled Lua VM cleanup failed: {}",
5448 render_error, cleanup_error
5449 )),
5450 }
5451 }
5452
5453 fn populate_vulcan_call_for_lua(
5455 lua: &Lua,
5456 skills_map: &HashMap<String, LoadedSkill>,
5457 entry_registry: &BTreeMap<String, ResolvedEntryTarget>,
5458 host_options: Arc<LuaRuntimeHostOptions>,
5459 lancedb_host: Option<Arc<LanceDbSkillHost>>,
5460 sqlite_host: Option<Arc<SqliteSkillHost>>,
5461 ) -> Result<(), String> {
5462 let vulcan: Table = lua
5463 .globals()
5464 .get("vulcan")
5465 .map_err(|e| format!("vulcan module not found: {}", e))?;
5466
5467 #[derive(Clone)]
5470 struct DispatchEntry {
5471 display_name: String,
5474 module_name: String,
5477 owner_skill_id: String,
5480 local_name: String,
5483 owner_skill_dir: String,
5486 entry_path: String,
5489 }
5490
5491 let dispatch_entries: Vec<DispatchEntry> = entry_registry
5493 .values()
5494 .filter_map(|target| {
5495 let skill = skills_map.get(&target.skill_storage_key)?;
5496 let tool = skill.meta.find_tool_by_local_name(&target.local_name)?;
5497 let entry_path = tool_entry_path(&skill.dir, tool);
5498 Some(DispatchEntry {
5499 display_name: target.canonical_name.clone(),
5500 module_name: tool.lua_module.clone(),
5501 owner_skill_id: target.skill_id.clone(),
5502 local_name: target.local_name.clone(),
5503 owner_skill_dir: skill.dir.to_string_lossy().to_string(),
5504 entry_path: entry_path.to_string_lossy().to_string(),
5505 })
5506 })
5507 .collect();
5508
5509 let dispatcher = lua
5510 .create_function(move |lua, (name, args): (LuaValue, LuaValue)| {
5511 let name = require_string_arg(name, "call", "name", false)?;
5512 let args = require_table_arg(args, "call", "args")?;
5513 let dispatch_entry = dispatch_entries
5514 .iter()
5515 .find(|entry| entry.display_name == name)
5516 .ok_or_else(|| mlua::Error::runtime(format!("Skill '{}' not found", name)))?;
5517 let module = &dispatch_entry.module_name;
5518 let owner_skill_name = &dispatch_entry.owner_skill_id;
5519 let func_name = format!("__skill_{}", module);
5520 let func: Function = lua.globals().get(func_name.as_str()).map_err(|_| {
5521 mlua::Error::runtime(format!("Skill function '{}' not found", module))
5522 })?;
5523 let nested_scope_guard = LuaNestedCallScopeGuard::new(
5524 lua,
5525 host_options.clone(),
5526 lancedb_host.clone(),
5527 sqlite_host.clone(),
5528 )
5529 .map_err(mlua::Error::runtime)?;
5530 let current_request_context_json =
5531 lua_value_to_json(&nested_scope_guard.previous_context)
5532 .map_err(mlua::Error::runtime)?;
5533 let current_request_context = match ¤t_request_context_json {
5534 Value::Object(object) if object.is_empty() => None,
5535 _ => serde_json::from_value::<RuntimeRequestContext>(
5536 current_request_context_json,
5537 )
5538 .ok(),
5539 };
5540 let current_client_budget =
5541 lua_value_to_json(&nested_scope_guard.previous_client_budget)
5542 .map_err(mlua::Error::runtime)?;
5543 let current_tool_config =
5544 lua_value_to_json(&nested_scope_guard.previous_tool_config)
5545 .map_err(mlua::Error::runtime)?;
5546 if nested_scope_guard.previous_internal_context.luaexec_active {
5547 if nested_scope_guard
5548 .previous_internal_context
5549 .luaexec_caller_tool_name
5550 .as_deref()
5551 == Some(dispatch_entry.display_name.as_str())
5552 {
5553 return Err(mlua::Error::runtime(format!(
5554 "vulcan.call cannot call the current luaexec caller tool '{}'",
5555 dispatch_entry.display_name
5556 )));
5557 }
5558 if dispatch_entry.owner_skill_id == "vulcan-runtime"
5559 && (dispatch_entry.local_name == "lua-exec"
5560 || dispatch_entry.local_name == "lua-file")
5561 {
5562 return Err(mlua::Error::runtime(format!(
5563 "vulcan.call cannot invoke '{}' inside luaexec",
5564 dispatch_entry.display_name
5565 )));
5566 }
5567 }
5568 let target_binding = match lancedb_host.as_ref() {
5569 Some(host) => host
5570 .binding_for_skill(owner_skill_name)
5571 .map_err(mlua::Error::runtime)?,
5572 None => None,
5573 };
5574 let target_sqlite_binding = match sqlite_host.as_ref() {
5575 Some(host) => host
5576 .binding_for_skill(owner_skill_name)
5577 .map_err(mlua::Error::runtime)?,
5578 None => None,
5579 };
5580 let nested_invocation_context = LuaInvocationContext::new(
5581 current_request_context,
5582 current_client_budget,
5583 current_tool_config,
5584 );
5585 nested_scope_guard
5586 .enter_nested_call(
5587 &dispatch_entry.display_name,
5588 owner_skill_name,
5589 &dispatch_entry.owner_skill_dir,
5590 &dispatch_entry.entry_path,
5591 &nested_invocation_context,
5592 target_binding,
5593 target_sqlite_binding,
5594 )
5595 .map_err(mlua::Error::runtime)?;
5596 let call_result = func.call::<MultiValue>(args);
5597 let restore_result = nested_scope_guard.finish().map_err(mlua::Error::runtime);
5598 match (call_result, restore_result) {
5599 (Ok(result), Ok(())) => Ok(result),
5600 (Ok(_), Err(restore_error)) => Err(restore_error),
5601 (Err(call_error), Ok(())) => Err(call_error),
5602 (Err(call_error), Err(restore_error)) => Err(mlua::Error::runtime(format!(
5603 "{}; nested vulcan.call restore failed: {}",
5604 call_error, restore_error
5605 ))),
5606 }
5607 })
5608 .map_err(|e| format!("Failed to create vulcan.call dispatcher: {}", e))?;
5609
5610 vulcan
5611 .set("call", dispatcher)
5612 .map_err(|e| format!("Failed to set vulcan.call: {}", e))?;
5613
5614 Ok(())
5615 }
5616
5617 fn setup_package_paths(
5630 lua: &Lua,
5631 host_options: &LuaRuntimeHostOptions,
5632 ) -> Result<(), Box<dyn std::error::Error>> {
5633 let Some(lua_packages) = host_options.lua_packages_dir.as_ref() else {
5634 return Ok(());
5635 };
5636 if !lua_packages.exists() {
5637 return Ok(());
5638 }
5639
5640 #[cfg(windows)]
5643 let cpath_pattern = format!(
5644 "{}\\lib\\lua\\?.dll;{}\\lib\\lua\\?\\init.dll;{}\\lib\\lua\\loadall.dll;{}\\?\\?.dll;",
5645 lua_packages.display(),
5646 lua_packages.display(),
5647 lua_packages.display(),
5648 lua_packages.display()
5649 );
5650
5651 #[cfg(target_os = "linux")]
5654 let cpath_pattern = format!(
5655 "{}/lib/lua/?.so;{}/lib/lua/?/init.so;{}/lib/lua/loadall.so;{}/?.so;",
5656 lua_packages.display(),
5657 lua_packages.display(),
5658 lua_packages.display(),
5659 lua_packages.display()
5660 );
5661
5662 #[cfg(target_os = "macos")]
5665 let cpath_pattern = format!(
5666 "{}/lib/lua/?.dylib;{}/lib/lua/?/init.dylib;{}/lib/lua/loadall.dylib;{}/?.dylib;",
5667 lua_packages.display(),
5668 lua_packages.display(),
5669 lua_packages.display(),
5670 lua_packages.display()
5671 );
5672
5673 #[cfg(windows)]
5676 let path_pattern = format!(
5677 "{}\\share\\lua\\?.lua;{}\\share\\lua\\?\\init.lua;{}\\?.lua;",
5678 lua_packages.display(),
5679 lua_packages.display(),
5680 lua_packages.display()
5681 );
5682
5683 #[cfg(unix)]
5686 let path_pattern = format!(
5687 "{}/share/lua/?.lua;{}/share/lua/?/init.lua;{}/?.lua;",
5688 lua_packages.display(),
5689 lua_packages.display(),
5690 lua_packages.display()
5691 );
5692
5693 let package: Table = lua.globals().get("package")?;
5696 let old_cpath: mlua::String = package.get("cpath")?;
5697 let new_cpath = format!("{}{}", cpath_pattern, old_cpath.to_str()?.to_string());
5698 package.set("cpath", lua.create_string(&new_cpath)?)?;
5699
5700 let old_path: mlua::String = package.get("path")?;
5701 let new_path = format!("{}{}", path_pattern, old_path.to_str()?.to_string());
5702 package.set("path", lua.create_string(&new_path)?)?;
5703 Ok(())
5704 }
5705
5706 fn register_vulcan_module(
5709 lua: &Lua,
5710 host_options: &LuaRuntimeHostOptions,
5711 skill_config_store: Arc<SkillConfigStore>,
5712 ) -> Result<(), Box<dyn std::error::Error>> {
5713 let vulcan = lua.create_table()?;
5714 let runtime = lua.create_table()?;
5715 let runtime_skills = lua.create_table()?;
5716 let runtime_internal = lua.create_table()?;
5717 let runtime_lua = lua.create_table()?;
5718 let fs = lua.create_table()?;
5719 let path = lua.create_table()?;
5720 let process = lua.create_table()?;
5721 let os = lua.create_table()?;
5722 let json = lua.create_table()?;
5723 let cache = lua.create_table()?;
5724 let config = lua.create_table()?;
5725 let context = lua.create_table()?;
5726 let deps = lua.create_table()?;
5727
5728 let runtime_log_fn = lua.create_function(|_, (level, msg): (LuaValue, LuaValue)| {
5729 let level = require_string_arg(level, "runtime.log", "level", false)?;
5730 let msg = require_string_arg(msg, "runtime.log", "message", true)?;
5731 let normalized_level = level.trim().to_ascii_lowercase();
5732 let rendered = format!("[LuaSkill:{}] {}", level, msg);
5733 if normalized_level.contains("error") || normalized_level.contains("fatal") {
5734 log_error(rendered);
5735 } else if normalized_level.contains("warn") {
5736 log_warn(rendered);
5737 } else {
5738 log_info(rendered);
5739 }
5740 Ok(())
5741 })?;
5742 runtime.set("log", runtime_log_fn)?;
5743
5744 let print_fn = lua.create_function(|_, args: MultiValue| {
5745 let mut parts = Vec::new();
5746 for val in args.into_iter() {
5747 let s = match val {
5748 LuaValue::String(s) => s.to_str().map(|b| b.to_string()).unwrap_or_default(),
5749 LuaValue::Integer(i) => i.to_string(),
5750 LuaValue::Number(f) => f.to_string(),
5751 LuaValue::Boolean(b) => b.to_string(),
5752 LuaValue::Nil => "nil".to_string(),
5753 _ => format!("{:?}", val),
5754 };
5755 parts.push(s);
5756 }
5757 log_info(format!("[LuaSkill:info] {}", parts.join("\t")));
5758 Ok(())
5759 })?;
5760 lua.globals().set("print", print_fn)?;
5761
5762 let fs_list_fn = lua.create_function(|_, dir: LuaValue| {
5763 let dir = require_path_arg(dir, "fs.list", "dir")?;
5764 let mut entries = Vec::new();
5765 for entry in std::fs::read_dir(&dir)
5766 .map_err(|e| mlua::Error::runtime(format!("fs.list: {}", e)))?
5767 {
5768 let entry = entry.map_err(|e| mlua::Error::runtime(format!("fs.list: {}", e)))?;
5769 let file_name = entry.file_name().into_string().map_err(|name| {
5770 mlua::Error::runtime(format!(
5771 "fs.list: non-UTF-8 file name under {}: {:?}",
5772 Path::new(&dir).display(),
5773 name
5774 ))
5775 })?;
5776 entries.push(file_name);
5777 }
5778 Ok(entries)
5779 })?;
5780 fs.set("list", fs_list_fn)?;
5781
5782 let fs_read_fn = lua.create_function(|_, path: LuaValue| {
5783 let path = require_path_arg(path, "fs.read", "path")?;
5784 std::fs::read_to_string(&path)
5785 .map_err(|e| mlua::Error::runtime(format!("fs.read: {}", e)))
5786 })?;
5787 fs.set("read", fs_read_fn)?;
5788
5789 let fs_write_fn = lua.create_function(|_, (path, content): (LuaValue, LuaValue)| {
5790 let path = require_path_arg(path, "fs.write", "path")?;
5791 let content = require_string_arg(content, "fs.write", "content", true)?;
5792 std::fs::write(&path, content)
5793 .map_err(|e| mlua::Error::runtime(format!("fs.write: {}", e)))
5794 })?;
5795 fs.set("write", fs_write_fn)?;
5796
5797 let fs_exists_fn = lua.create_function(|_, path: LuaValue| {
5798 let path = require_path_arg(path, "fs.exists", "path")?;
5799 Ok(Path::new(&path).exists())
5800 })?;
5801 fs.set("exists", fs_exists_fn)?;
5802
5803 let fs_is_dir_fn = lua.create_function(|_, path: LuaValue| {
5804 let path = require_path_arg(path, "fs.is_dir", "path")?;
5805 Ok(Path::new(&path).is_dir())
5806 })?;
5807 fs.set("is_dir", fs_is_dir_fn)?;
5808
5809 let path_join_fn = lua.create_function(|lua, parts: MultiValue| {
5810 if parts.is_empty() {
5811 return Err(mlua::Error::runtime(
5812 "path.join: expected at least one path segment",
5813 ));
5814 }
5815 let mut joined = PathBuf::new();
5816 for (index, val) in parts.into_iter().enumerate() {
5817 let param_name = format!("part[{}]", index + 1);
5818 let part = require_path_arg(val, "path.join", ¶m_name)?;
5819 joined.push(part);
5820 }
5821 let result = render_host_visible_path(&joined);
5822 lua.create_string(&result)
5823 })?;
5824 path.set("join", path_join_fn)?;
5825
5826 let cwd_fn = lua.create_function(|lua, ()| {
5827 let current_dir = std::env::current_dir()
5828 .map_err(|error| mlua::Error::runtime(format!("runtime.cwd: {}", error)))?;
5829 let current_dir_text = render_host_visible_path(¤t_dir);
5830 lua.create_string(¤t_dir_text)
5831 })?;
5832 runtime.set("cwd", cwd_fn)?;
5833
5834 match host_options.temp_dir.as_ref() {
5835 Some(path_buf) => runtime.set("temp_dir", render_host_visible_path(path_buf))?,
5836 None => runtime.set("temp_dir", LuaValue::Nil)?,
5837 }
5838
5839 match host_options.resources_dir.as_ref() {
5840 Some(path_buf) => runtime.set("resources_dir", render_host_visible_path(path_buf))?,
5841 None => runtime.set("resources_dir", LuaValue::Nil)?,
5842 }
5843
5844 let exec_fn = lua.create_function(|lua, spec: LuaValue| {
5845 let request = parse_exec_request(spec, "process.exec")?;
5846 let result = execute_exec_request(request);
5847 exec_result_to_lua_table(lua, result)
5848 })?;
5849 process.set("exec", exec_fn)?;
5850
5851 let os_info_fn = lua.create_function(|lua, ()| {
5852 let current_os = match std::env::consts::OS {
5853 "windows" => "windows",
5854 "linux" => "linux",
5855 "macos" => "macos",
5856 _ => std::env::consts::OS,
5857 };
5858 let arch = match std::env::consts::ARCH {
5859 "x86_64" => "x86_64",
5860 "x86" => "i686",
5861 "aarch64" => "aarch64",
5862 "arm" => "armv7l",
5863 _ => std::env::consts::ARCH,
5864 };
5865 let info = lua.create_table()?;
5866 info.set("os", current_os)?;
5867 info.set("arch", arch)?;
5868 Ok(info)
5869 })?;
5870 os.set("info", os_info_fn)?;
5871
5872 let json_encode_fn =
5873 lua.create_function(|lua, val: LuaValue| match lua_value_to_json(&val) {
5874 Ok(value) => lua.create_string(serde_json::to_string(&value).unwrap_or_default()),
5875 Err(error) => Err(mlua::Error::runtime(format!("json.encode: {}", error))),
5876 })?;
5877 json.set("encode", json_encode_fn)?;
5878
5879 let json_decode_fn = lua.create_function(|lua, s: LuaValue| {
5880 let s = require_string_arg(s, "json.decode", "text", false)?;
5881 match serde_json::from_str::<Value>(&s) {
5882 Ok(value) => json_value_to_lua(lua, &value),
5883 Err(error) => Err(mlua::Error::runtime(format!("json.decode: {}", error))),
5884 }
5885 })?;
5886 json.set("decode", json_decode_fn)?;
5887
5888 let cache_put_fn = lua.create_function(|lua, (value, ttl_sec): (LuaValue, LuaValue)| {
5889 let internal = get_vulcan_runtime_internal_table(lua).map_err(mlua::Error::runtime)?;
5890 let tool_name: Option<String> =
5891 internal.get("tool_name").map_err(mlua::Error::runtime)?;
5892 let skill_name: Option<String> =
5893 internal.get("skill_name").map_err(mlua::Error::runtime)?;
5894 let scope = tool_name
5895 .or(skill_name)
5896 .unwrap_or_else(|| "__runtime".to_string());
5897 let ttl_secs = optional_u64_arg(ttl_sec, "cache.put", "ttl_sec")?;
5898 let payload = lua_value_to_json(&value)
5899 .map_err(|error| mlua::Error::runtime(format!("cache.put: {}", error)))?;
5900 Ok(global_tool_cache().create(&scope, payload, ttl_secs))
5901 })?;
5902 cache.set("put", cache_put_fn)?;
5903
5904 let cache_get_fn = lua.create_function(|lua, cache_id: LuaValue| {
5905 let internal = get_vulcan_runtime_internal_table(lua).map_err(mlua::Error::runtime)?;
5906 let tool_name: Option<String> =
5907 internal.get("tool_name").map_err(mlua::Error::runtime)?;
5908 let skill_name: Option<String> =
5909 internal.get("skill_name").map_err(mlua::Error::runtime)?;
5910 let scope = tool_name
5911 .or(skill_name)
5912 .unwrap_or_else(|| "__runtime".to_string());
5913 let cache_id = require_string_arg(cache_id, "cache.get", "cache_id", false)?;
5914 match global_tool_cache().get(&scope, &cache_id) {
5915 Some(value) => json_value_to_lua(lua, &value),
5916 None => Ok(LuaValue::Nil),
5917 }
5918 })?;
5919 cache.set("get", cache_get_fn)?;
5920
5921 let cache_delete_fn = lua.create_function(|lua, cache_id: LuaValue| {
5922 let internal = get_vulcan_runtime_internal_table(lua).map_err(mlua::Error::runtime)?;
5923 let tool_name: Option<String> =
5924 internal.get("tool_name").map_err(mlua::Error::runtime)?;
5925 let skill_name: Option<String> =
5926 internal.get("skill_name").map_err(mlua::Error::runtime)?;
5927 let scope = tool_name
5928 .or(skill_name)
5929 .unwrap_or_else(|| "__runtime".to_string());
5930 let cache_id = require_string_arg(cache_id, "cache.delete", "cache_id", false)?;
5931 Ok(global_tool_cache().delete(&scope, &cache_id))
5932 })?;
5933 cache.set("delete", cache_delete_fn)?;
5934
5935 let config_get_store = skill_config_store.clone();
5936 let config_get_fn = lua.create_function(move |lua, key: LuaValue| {
5937 let key = require_string_arg(key, "config.get", "key", false)?;
5938 let skill_id = current_vulcan_config_skill_id(lua, "vulcan.config.get")?;
5939 match config_get_store
5940 .get_value(&skill_id, &key)
5941 .map_err(mlua::Error::runtime)?
5942 {
5943 Some(value) => Ok(LuaValue::String(
5944 lua.create_string(&value).map_err(mlua::Error::runtime)?,
5945 )),
5946 None => Ok(LuaValue::Nil),
5947 }
5948 })?;
5949 config.set("get", config_get_fn)?;
5950
5951 let config_has_store = skill_config_store.clone();
5952 let config_has_fn = lua.create_function(move |lua, key: LuaValue| {
5953 let key = require_string_arg(key, "config.has", "key", false)?;
5954 let skill_id = current_vulcan_config_skill_id(lua, "vulcan.config.has")?;
5955 config_has_store
5956 .has_value(&skill_id, &key)
5957 .map_err(mlua::Error::runtime)
5958 })?;
5959 config.set("has", config_has_fn)?;
5960
5961 let config_set_store = skill_config_store.clone();
5962 let config_set_fn =
5963 lua.create_function(move |lua, (key, value): (LuaValue, LuaValue)| {
5964 let key = require_string_arg(key, "config.set", "key", false)?;
5965 let value = require_string_arg(value, "config.set", "value", true)?;
5966 let skill_id = current_vulcan_config_skill_id(lua, "vulcan.config.set")?;
5967 config_set_store
5968 .set_value(&skill_id, &key, &value)
5969 .map_err(mlua::Error::runtime)?;
5970 Ok(true)
5971 })?;
5972 config.set("set", config_set_fn)?;
5973
5974 let config_delete_store = skill_config_store.clone();
5975 let config_delete_fn = lua.create_function(move |lua, key: LuaValue| {
5976 let key = require_string_arg(key, "config.delete", "key", false)?;
5977 let skill_id = current_vulcan_config_skill_id(lua, "vulcan.config.delete")?;
5978 config_delete_store
5979 .delete_value(&skill_id, &key)
5980 .map_err(mlua::Error::runtime)
5981 })?;
5982 config.set("delete", config_delete_fn)?;
5983
5984 let config_list_store = skill_config_store.clone();
5985 let config_list_fn = lua.create_function(move |lua, ()| {
5986 let skill_id = current_vulcan_config_skill_id(lua, "vulcan.config.list")?;
5987 let items = config_list_store
5988 .list_skill_values(&skill_id)
5989 .map_err(mlua::Error::runtime)?;
5990 let table = lua.create_table().map_err(mlua::Error::runtime)?;
5991 for (key, value) in items {
5992 table
5993 .set(
5994 key,
5995 LuaValue::String(lua.create_string(&value).map_err(mlua::Error::runtime)?),
5996 )
5997 .map_err(mlua::Error::runtime)?;
5998 }
5999 Ok(LuaValue::Table(table))
6000 })?;
6001 config.set("list", config_list_fn)?;
6002
6003 context.set("request", lua.create_table()?)?;
6004 context.set("client_info", LuaValue::Nil)?;
6005 context.set("client_capabilities", lua.create_table()?)?;
6006 context.set("client_budget", lua.create_table()?)?;
6007 context.set("tool_config", lua.create_table()?)?;
6008 context.set("skill_dir", LuaValue::Nil)?;
6009 context.set("entry_dir", LuaValue::Nil)?;
6010 context.set("entry_file", LuaValue::Nil)?;
6011 deps.set("tools_path", LuaValue::Nil)?;
6012 deps.set("lua_path", LuaValue::Nil)?;
6013 deps.set("ffi_path", LuaValue::Nil)?;
6014
6015 let skill_management_enabled = host_options.capabilities.enable_skill_management_bridge;
6016 runtime_skills.set("enabled", skill_management_enabled)?;
6017
6018 let runtime_skills_status_fn = lua.create_function(move |lua, ()| {
6019 let status = lua.create_table()?;
6020 let callback_registered =
6021 try_has_skill_management_callback().map_err(mlua::Error::runtime)?;
6022 status.set("enabled", skill_management_enabled)?;
6023 status.set("callback_registered", callback_registered)?;
6024 status.set("mode", "host_callback")?;
6025 let message = if !skill_management_enabled {
6026 "Skill management bridge is disabled by host policy"
6027 } else if callback_registered {
6028 "Skill management bridge is enabled and ready"
6029 } else {
6030 "Skill management bridge is enabled but no host callback is registered"
6031 };
6032 status.set("message", message)?;
6033 Ok(status)
6034 })?;
6035 runtime_skills.set("status", runtime_skills_status_fn)?;
6036 runtime_skills.set(
6037 "install",
6038 create_runtime_skill_management_bridge_fn(
6039 lua,
6040 skill_management_enabled,
6041 RuntimeSkillManagementAction::Install,
6042 "install",
6043 )?,
6044 )?;
6045 runtime_skills.set(
6046 "update",
6047 create_runtime_skill_management_bridge_fn(
6048 lua,
6049 skill_management_enabled,
6050 RuntimeSkillManagementAction::Update,
6051 "update",
6052 )?,
6053 )?;
6054 runtime_skills.set(
6055 "uninstall",
6056 create_runtime_skill_management_bridge_fn(
6057 lua,
6058 skill_management_enabled,
6059 RuntimeSkillManagementAction::Uninstall,
6060 "uninstall",
6061 )?,
6062 )?;
6063 runtime_skills.set(
6064 "enable",
6065 create_runtime_skill_management_bridge_fn(
6066 lua,
6067 skill_management_enabled,
6068 RuntimeSkillManagementAction::Enable,
6069 "enable",
6070 )?,
6071 )?;
6072 runtime_skills.set(
6073 "disable",
6074 create_runtime_skill_management_bridge_fn(
6075 lua,
6076 skill_management_enabled,
6077 RuntimeSkillManagementAction::Disable,
6078 "disable",
6079 )?,
6080 )?;
6081
6082 let overflow_type = lua.create_table()?;
6083 overflow_type.set("truncate", "truncate")?;
6084 overflow_type.set("page", "page")?;
6085 runtime.set("overflow_type", overflow_type)?;
6086
6087 runtime_internal.set("tool_name", LuaValue::Nil)?;
6088 runtime_internal.set("skill_name", LuaValue::Nil)?;
6089 runtime_internal.set("luaexec_active", false)?;
6090 runtime_internal.set("luaexec_caller_tool_name", LuaValue::Nil)?;
6091 runtime.set("internal", runtime_internal)?;
6092 runtime.set("skills", runtime_skills)?;
6093 runtime.set("lua", runtime_lua)?;
6094
6095 let call_stub = lua.create_function(|_, _: (LuaValue, LuaValue)| {
6096 Err::<(), _>(mlua::Error::runtime("vulcan.call not initialized"))
6097 })?;
6098 vulcan.set("call", call_stub)?;
6099 vulcan.set("runtime", runtime)?;
6100 vulcan.set("fs", fs)?;
6101 vulcan.set("path", path)?;
6102 vulcan.set("process", process)?;
6103 vulcan.set("os", os)?;
6104 vulcan.set("json", json)?;
6105 vulcan.set("cache", cache)?;
6106 vulcan.set("config", config)?;
6107 vulcan.set("context", context)?;
6108 vulcan.set("deps", deps)?;
6109
6110 lua.globals().set("vulcan", vulcan)?;
6111 Ok(())
6112 }
6113}
6114
6115fn json_to_lua_table(lua: &Lua, json: &Value) -> Result<Table, String> {
6120 json_to_lua_table_inner(lua, json).map_err(|e| e.to_string())
6121}
6122
6123fn json_to_lua_table_inner(lua: &Lua, json: &Value) -> mlua::Result<Table> {
6124 let table = lua.create_table()?;
6125 if let Value::Object(obj) = json {
6126 for (k, v) in obj {
6127 table.set(k.as_str(), json_value_to_lua(lua, v)?)?;
6128 }
6129 } else if let Value::Array(arr) = json {
6130 for (i, v) in arr.iter().enumerate() {
6131 table.set(i + 1, json_value_to_lua(lua, v)?)?;
6132 }
6133 }
6134 Ok(table)
6135}
6136
6137fn json_value_to_lua(lua: &Lua, json: &Value) -> mlua::Result<LuaValue> {
6138 match json {
6139 Value::Null => Ok(LuaValue::Nil),
6140 Value::Bool(b) => Ok(LuaValue::Boolean(*b)),
6141 Value::Number(n) => {
6142 if let Some(i) = n.as_i64() {
6143 Ok(LuaValue::Integer(i))
6144 } else {
6145 Ok(LuaValue::Number(n.as_f64().unwrap_or(0.0)))
6146 }
6147 }
6148 Value::String(s) => Ok(LuaValue::String(lua.create_string(s)?)),
6149 Value::Array(_) | Value::Object(_) => {
6150 Ok(LuaValue::Table(json_to_lua_table_inner(lua, json)?))
6151 }
6152 }
6153}
6154
6155fn lua_value_to_json(val: &LuaValue) -> Result<Value, String> {
6156 match val {
6157 LuaValue::Nil => Ok(Value::Null),
6158 LuaValue::Boolean(b) => Ok(Value::Bool(*b)),
6159 LuaValue::Integer(i) => Ok(Value::Number((*i).into())),
6160 LuaValue::Number(f) => {
6161 if let Some(n) = serde_json::Number::from_f64(*f) {
6162 Ok(Value::Number(n))
6163 } else {
6164 Ok(Value::Null)
6165 }
6166 }
6167 LuaValue::String(s) => Ok(Value::String(
6168 s.to_str().map(|b| b.to_string()).unwrap_or_default(),
6169 )),
6170 LuaValue::Table(t) => {
6171 if t.raw_len() > 0 {
6173 let arr = lua_table_to_array(t)?;
6174 Ok(Value::Array(arr))
6175 } else {
6176 lua_table_to_object(t)
6177 }
6178 }
6179 LuaValue::Function(_) => Err("Cannot convert Lua function to JSON".to_string()),
6180 LuaValue::Thread(_) => Err("Cannot convert Lua thread to JSON".to_string()),
6181 LuaValue::UserData(_) => Err("Cannot convert Lua userdata to JSON".to_string()),
6182 LuaValue::LightUserData(_) => Err("Cannot convert light userdata to JSON".to_string()),
6183 _ => Err("Unknown Lua value type".to_string()),
6184 }
6185}
6186
6187fn lua_table_to_array(t: &Table) -> Result<Vec<Value>, String> {
6188 let len = t.raw_len();
6189 if len == 0 {
6190 return Ok(Vec::new());
6192 }
6193 let mut arr = Vec::with_capacity(len);
6194 for i in 1..=len {
6195 let val: LuaValue = t.get(i).map_err(|e| format!("Array index {}: {}", i, e))?;
6196 arr.push(lua_value_to_json(&val)?);
6197 }
6198 Ok(arr)
6199}
6200
6201fn lua_table_to_object(t: &Table) -> Result<Value, String> {
6202 let mut obj = serde_json::Map::new();
6203 for pair in t.pairs::<String, LuaValue>() {
6204 let (k, v) = pair.map_err(|e| format!("Table key: {}", e))?;
6205 obj.insert(k, lua_value_to_json(&v)?);
6206 }
6207 if obj.is_empty() && t.raw_len() == 0 {
6210 return Ok(Value::Array(Vec::new()));
6211 }
6212 Ok(Value::Object(obj))
6213}
6214
6215#[cfg(test)]
6216mod tests {
6217 use super::{
6218 LoadedSkill, LuaEngine, LuaVmPool, LuaVmPoolConfig, LuaVmPoolState, LuaVmRequestScopeGuard,
6219 SkillConfigStore, VulcanInternalExecutionContext, default_runlua_vm_pool_config,
6220 get_vulcan_context_table, get_vulcan_deps_table, get_vulcan_runtime_internal_table,
6221 get_vulcan_table, json_to_lua_table, normalize_host_visible_path_text,
6222 populate_vulcan_dependency_context, populate_vulcan_file_context,
6223 populate_vulcan_internal_execution_context,
6224 };
6225 use crate::host::database::RuntimeDatabaseProviderCallbacks;
6226 use crate::lua_skill::SkillMeta;
6227 use crate::runtime_options::LuaRuntimeRunLuaPoolConfig;
6228 use crate::{LuaEngineOptions, LuaRuntimeHostOptions};
6229 use mlua::{Table, Value as LuaValue};
6230 use serde_json::json;
6231 use std::collections::HashMap;
6232 use std::fs;
6233 use std::path::Path;
6234 use std::path::PathBuf;
6235 use std::sync::{Arc, Condvar, Mutex};
6236
6237 fn make_loaded_skill(
6240 directory_name: &str,
6241 skill_id: &str,
6242 local_entry_name: &str,
6243 lua_module: &str,
6244 ) -> LoadedSkill {
6245 let mut meta: SkillMeta = serde_yaml::from_str(&format!("name: {skill_id}\nversion: 0.1.0\nenable: true\ndebug: false\nentries:\n - name: {local_entry_name}\n lua_entry: runtime/test.lua\n lua_module: {lua_module}\n"))
6246 .expect("deserialize minimal skill meta");
6247 meta.bind_directory_skill_id(skill_id.to_string());
6248 LoadedSkill {
6249 meta,
6250 dir: PathBuf::from(format!("D:/tests/{directory_name}")),
6251 root_name: "ROOT".to_string(),
6252 lancedb_binding: None,
6253 sqlite_binding: None,
6254 resolved_entry_names: HashMap::new(),
6255 }
6256 }
6257
6258 #[cfg(windows)]
6261 #[test]
6262 fn normalize_host_visible_path_text_strips_windows_drive_verbatim_prefix() {
6263 assert_eq!(
6264 normalize_host_visible_path_text(r"\\?\C:\runtime-test-root\skill.lua"),
6265 r"C:\runtime-test-root\skill.lua"
6266 );
6267 }
6268
6269 #[cfg(windows)]
6272 #[test]
6273 fn normalize_host_visible_path_text_strips_windows_unc_verbatim_prefix() {
6274 assert_eq!(
6275 normalize_host_visible_path_text(r"\\?\UNC\server\share\skill.lua"),
6276 r"\\server\share\skill.lua"
6277 );
6278 }
6279
6280 fn make_test_engine(skills: HashMap<String, LoadedSkill>) -> LuaEngine {
6283 LuaEngine {
6284 skills,
6285 entry_registry: Default::default(),
6286 pool: Arc::new(LuaVmPool {
6287 config: LuaVmPoolConfig {
6288 min_size: 1,
6289 max_size: 1,
6290 idle_ttl_secs: 60,
6291 },
6292 state: Mutex::new(LuaVmPoolState {
6293 available: Vec::new(),
6294 total_count: 0,
6295 }),
6296 condvar: Condvar::new(),
6297 }),
6298 runlua_pool: Arc::new(LuaVmPool::new(default_runlua_vm_pool_config())),
6299 skill_config_store: Arc::new(
6300 SkillConfigStore::new(None).expect("create runtime test skill config store"),
6301 ),
6302 lancedb_host: None,
6303 sqlite_host: None,
6304 database_provider_callbacks: Arc::new(RuntimeDatabaseProviderCallbacks::default()),
6305 host_options: Arc::new(LuaRuntimeHostOptions::default()),
6306 }
6307 }
6308
6309 fn make_runtime_test_engine() -> LuaEngine {
6312 LuaEngine::new(LuaEngineOptions {
6313 host_options: LuaRuntimeHostOptions::default(),
6314 pool_config: LuaVmPoolConfig {
6315 min_size: 1,
6316 max_size: 1,
6317 idle_ttl_secs: 60,
6318 },
6319 })
6320 .expect("create runtime test engine")
6321 }
6322
6323 fn make_temp_runtime_root(label: &str) -> PathBuf {
6326 std::env::temp_dir().join(format!(
6327 "vulcan_luaskills_{}_{}_{}",
6328 label,
6329 std::process::id(),
6330 label.len()
6331 ))
6332 }
6333
6334 fn create_runtime_test_layout(runtime_root: &Path) {
6337 for relative_path in [
6338 "skills",
6339 "temp",
6340 "resources",
6341 "lua_packages",
6342 "bin/tools",
6343 "libs",
6344 ] {
6345 fs::create_dir_all(runtime_root.join(relative_path))
6346 .expect("create runtime test layout path");
6347 }
6348 }
6349
6350 fn write_skill_config_test_skill(runtime_root: &Path, skill_id: &str) -> PathBuf {
6353 let skill_dir = runtime_root.join("skills").join(skill_id);
6354 fs::create_dir_all(skill_dir.join("runtime")).expect("create config test runtime dir");
6355 fs::write(
6356 skill_dir.join("skill.yaml"),
6357 format!(
6358 "name: {skill_id}\nversion: 0.1.0\nenable: true\ndebug: false\nentries:\n - name: ping\n description: Config ping entry.\n lua_entry: runtime/ping.lua\n lua_module: {skill_id}.ping\n"
6359 ),
6360 )
6361 .expect("write config test skill yaml");
6362 fs::write(
6363 skill_dir.join("runtime").join("ping.lua"),
6364 "return function(args)\n local value = vulcan.config.get(\"api_token\")\n if value == nil then\n return \"missing\"\n end\n return value\nend\n",
6365 )
6366 .expect("write config test runtime entry");
6367 skill_dir
6368 }
6369
6370 #[test]
6373 fn runlua_pool_uses_default_config_when_host_does_not_override() {
6374 let engine = make_runtime_test_engine();
6375 assert_eq!(engine.runlua_pool.config.min_size, 1);
6376 assert_eq!(engine.runlua_pool.config.max_size, 4);
6377 assert_eq!(engine.runlua_pool.config.idle_ttl_secs, 60);
6378 }
6379
6380 #[test]
6383 fn runlua_pool_honors_host_override_config() {
6384 let mut host_options = LuaRuntimeHostOptions::default();
6385 host_options.runlua_pool_config = Some(LuaRuntimeRunLuaPoolConfig {
6386 min_size: 2,
6387 max_size: 5,
6388 idle_ttl_secs: 90,
6389 });
6390 let engine = LuaEngine::new(LuaEngineOptions {
6391 host_options,
6392 pool_config: LuaVmPoolConfig {
6393 min_size: 1,
6394 max_size: 1,
6395 idle_ttl_secs: 60,
6396 },
6397 })
6398 .expect("create runtime test engine with custom runlua pool");
6399 assert_eq!(engine.runlua_pool.config.min_size, 2);
6400 assert_eq!(engine.runlua_pool.config.max_size, 5);
6401 assert_eq!(engine.runlua_pool.config.idle_ttl_secs, 90);
6402 }
6403
6404 #[test]
6407 fn skill_config_engine_api_persists_values_into_explicit_file() {
6408 let runtime_root = make_temp_runtime_root("skill_config_explicit_path");
6409 if runtime_root.exists() {
6410 let _ = fs::remove_dir_all(&runtime_root);
6411 }
6412 create_runtime_test_layout(&runtime_root);
6413 let config_file = runtime_root.join("custom").join("skill_config.json");
6414
6415 let mut host_options = LuaRuntimeHostOptions::default();
6416 host_options.skill_config_file_path = Some(config_file.clone());
6417 let mut engine = LuaEngine::new(LuaEngineOptions {
6418 host_options,
6419 pool_config: LuaVmPoolConfig {
6420 min_size: 1,
6421 max_size: 1,
6422 idle_ttl_secs: 60,
6423 },
6424 })
6425 .expect("create skill config test engine");
6426
6427 engine
6428 .set_skill_config_value("demo-skill", "api_token", "sk-explicit")
6429 .expect("set explicit skill config");
6430 assert_eq!(
6431 engine
6432 .get_skill_config_value("demo-skill", "api_token")
6433 .expect("read explicit skill config"),
6434 Some("sk-explicit".to_string())
6435 );
6436 let entries = engine
6437 .list_skill_config_entries(Some("demo-skill"))
6438 .expect("list explicit skill config");
6439 assert_eq!(entries.len(), 1);
6440 assert_eq!(entries[0].skill_id, "demo-skill");
6441 assert_eq!(entries[0].key, "api_token");
6442 assert_eq!(entries[0].value, "sk-explicit");
6443 assert!(config_file.exists());
6444
6445 let deleted = engine
6446 .delete_skill_config_value("demo-skill", "api_token")
6447 .expect("delete explicit skill config");
6448 assert!(deleted);
6449 assert_eq!(
6450 engine
6451 .get_skill_config_value("demo-skill", "api_token")
6452 .expect("read deleted explicit skill config"),
6453 None
6454 );
6455
6456 let _ = fs::remove_dir_all(&runtime_root);
6457 }
6458
6459 #[test]
6462 fn skill_config_store_uses_default_runtime_config_file_after_load() {
6463 let runtime_root = make_temp_runtime_root("skill_config_default_path");
6464 if runtime_root.exists() {
6465 let _ = fs::remove_dir_all(&runtime_root);
6466 }
6467 create_runtime_test_layout(&runtime_root);
6468
6469 let mut engine = LuaEngine::new(LuaEngineOptions {
6470 host_options: LuaRuntimeHostOptions::default(),
6471 pool_config: LuaVmPoolConfig {
6472 min_size: 1,
6473 max_size: 1,
6474 idle_ttl_secs: 60,
6475 },
6476 })
6477 .expect("create default skill config test engine");
6478
6479 engine
6480 .load_from_roots(&[crate::host::options::RuntimeSkillRoot {
6481 name: "ROOT".to_string(),
6482 skills_dir: runtime_root.join("skills"),
6483 }])
6484 .expect("load empty roots for default skill config path");
6485
6486 let expected_path = runtime_root.join("config").join("skill_config.json");
6487 assert_eq!(
6488 engine
6489 .skill_config_store
6490 .file_path()
6491 .expect("resolve default skill config file path"),
6492 expected_path
6493 );
6494
6495 engine
6496 .set_skill_config_value("demo-skill", "endpoint", "https://example.test")
6497 .expect("write default skill config");
6498 assert!(expected_path.exists());
6499
6500 let _ = fs::remove_dir_all(&runtime_root);
6501 }
6502
6503 #[test]
6506 fn skill_config_store_initializes_default_path_before_skills_dir_exists() {
6507 let runtime_root = make_temp_runtime_root("skill_config_without_skills_dir");
6508 if runtime_root.exists() {
6509 let _ = fs::remove_dir_all(&runtime_root);
6510 }
6511 fs::create_dir_all(&runtime_root).expect("create runtime root without skills dir");
6512
6513 let missing_skills_dir = runtime_root.join("skills");
6514 let mut engine = LuaEngine::new(LuaEngineOptions {
6515 host_options: LuaRuntimeHostOptions::default(),
6516 pool_config: LuaVmPoolConfig {
6517 min_size: 1,
6518 max_size: 1,
6519 idle_ttl_secs: 60,
6520 },
6521 })
6522 .expect("create config path initialization test engine");
6523
6524 engine
6525 .load_from_roots(&[crate::host::options::RuntimeSkillRoot {
6526 name: "ROOT".to_string(),
6527 skills_dir: missing_skills_dir,
6528 }])
6529 .expect("load roots without an existing skills directory");
6530
6531 let expected_path = runtime_root.join("config").join("skill_config.json");
6532 assert_eq!(
6533 engine
6534 .skill_config_store
6535 .file_path()
6536 .expect("resolve config path without skills directory"),
6537 expected_path
6538 );
6539
6540 engine
6541 .set_skill_config_value("demo-skill", "api_token", "sk-before-install")
6542 .expect("write config before any skills directory exists");
6543 assert!(expected_path.exists());
6544
6545 let _ = fs::remove_dir_all(&runtime_root);
6546 }
6547
6548 #[test]
6551 fn reload_from_roots_updates_default_skill_config_path() {
6552 let first_runtime_root = make_temp_runtime_root("skill_config_reload_first");
6553 let second_runtime_root = make_temp_runtime_root("skill_config_reload_second");
6554 if first_runtime_root.exists() {
6555 let _ = fs::remove_dir_all(&first_runtime_root);
6556 }
6557 if second_runtime_root.exists() {
6558 let _ = fs::remove_dir_all(&second_runtime_root);
6559 }
6560 create_runtime_test_layout(&first_runtime_root);
6561 create_runtime_test_layout(&second_runtime_root);
6562
6563 let mut engine = LuaEngine::new(LuaEngineOptions {
6564 host_options: LuaRuntimeHostOptions::default(),
6565 pool_config: LuaVmPoolConfig {
6566 min_size: 1,
6567 max_size: 1,
6568 idle_ttl_secs: 60,
6569 },
6570 })
6571 .expect("create reload skill config test engine");
6572
6573 engine
6574 .load_from_roots(&[crate::host::options::RuntimeSkillRoot {
6575 name: "ROOT_FIRST".to_string(),
6576 skills_dir: first_runtime_root.join("skills"),
6577 }])
6578 .expect("load first runtime root");
6579 assert_eq!(
6580 engine
6581 .skill_config_store
6582 .file_path()
6583 .expect("resolve first config path"),
6584 first_runtime_root.join("config").join("skill_config.json")
6585 );
6586
6587 engine
6588 .reload_from_roots(&[crate::host::options::RuntimeSkillRoot {
6589 name: "ROOT_SECOND".to_string(),
6590 skills_dir: second_runtime_root.join("skills"),
6591 }])
6592 .expect("reload second runtime root");
6593 assert_eq!(
6594 engine
6595 .skill_config_store
6596 .file_path()
6597 .expect("resolve second config path"),
6598 second_runtime_root.join("config").join("skill_config.json")
6599 );
6600
6601 let _ = fs::remove_dir_all(&first_runtime_root);
6602 let _ = fs::remove_dir_all(&second_runtime_root);
6603 }
6604
6605 #[test]
6608 fn load_from_roots_accepts_explicit_skill_config_path_for_ambiguous_runtime_roots() {
6609 let first_runtime_root = make_temp_runtime_root("skill_config_explicit_ambiguous_first");
6610 let second_runtime_root = make_temp_runtime_root("skill_config_explicit_ambiguous_second");
6611 if first_runtime_root.exists() {
6612 let _ = fs::remove_dir_all(&first_runtime_root);
6613 }
6614 if second_runtime_root.exists() {
6615 let _ = fs::remove_dir_all(&second_runtime_root);
6616 }
6617 fs::create_dir_all(&first_runtime_root)
6618 .expect("create first explicit ambiguous runtime root");
6619 fs::create_dir_all(&second_runtime_root)
6620 .expect("create second explicit ambiguous runtime root");
6621 let explicit_config_file = first_runtime_root.join("custom").join("skill_config.json");
6622
6623 let mut host_options = LuaRuntimeHostOptions::default();
6624 host_options.skill_config_file_path = Some(explicit_config_file.clone());
6625 let mut engine = LuaEngine::new(LuaEngineOptions {
6626 host_options,
6627 pool_config: LuaVmPoolConfig {
6628 min_size: 1,
6629 max_size: 1,
6630 idle_ttl_secs: 60,
6631 },
6632 })
6633 .expect("create explicit ambiguous root test engine");
6634
6635 engine
6636 .load_from_roots(&[
6637 crate::host::options::RuntimeSkillRoot {
6638 name: "ROOT_A".to_string(),
6639 skills_dir: first_runtime_root.join("skills"),
6640 },
6641 crate::host::options::RuntimeSkillRoot {
6642 name: "ROOT_B".to_string(),
6643 skills_dir: second_runtime_root.join("skills"),
6644 },
6645 ])
6646 .expect("explicit config path should bypass ambiguous runtime roots");
6647
6648 assert_eq!(
6649 engine
6650 .skill_config_store
6651 .file_path()
6652 .expect("resolve explicit config path"),
6653 explicit_config_file
6654 );
6655
6656 let _ = fs::remove_dir_all(&first_runtime_root);
6657 let _ = fs::remove_dir_all(&second_runtime_root);
6658 }
6659
6660 #[test]
6663 fn load_from_roots_rejects_ambiguous_default_skill_config_runtime_root() {
6664 let first_runtime_root = make_temp_runtime_root("skill_config_ambiguous_first");
6665 let second_runtime_root = make_temp_runtime_root("skill_config_ambiguous_second");
6666 if first_runtime_root.exists() {
6667 let _ = fs::remove_dir_all(&first_runtime_root);
6668 }
6669 if second_runtime_root.exists() {
6670 let _ = fs::remove_dir_all(&second_runtime_root);
6671 }
6672 fs::create_dir_all(&first_runtime_root).expect("create first ambiguous runtime root");
6673 fs::create_dir_all(&second_runtime_root).expect("create second ambiguous runtime root");
6674
6675 let mut engine = LuaEngine::new(LuaEngineOptions {
6676 host_options: LuaRuntimeHostOptions::default(),
6677 pool_config: LuaVmPoolConfig {
6678 min_size: 1,
6679 max_size: 1,
6680 idle_ttl_secs: 60,
6681 },
6682 })
6683 .expect("create ambiguous root test engine");
6684
6685 let error = engine
6686 .load_from_roots(&[
6687 crate::host::options::RuntimeSkillRoot {
6688 name: "ROOT_A".to_string(),
6689 skills_dir: first_runtime_root.join("skills"),
6690 },
6691 crate::host::options::RuntimeSkillRoot {
6692 name: "ROOT_B".to_string(),
6693 skills_dir: second_runtime_root.join("skills"),
6694 },
6695 ])
6696 .expect_err("ambiguous runtime roots should require an explicit config file path");
6697 assert!(
6698 error
6699 .to_string()
6700 .contains("set host_options.skill_config_file_path explicitly"),
6701 "unexpected ambiguous root error: {error}"
6702 );
6703
6704 let _ = fs::remove_dir_all(&first_runtime_root);
6705 let _ = fs::remove_dir_all(&second_runtime_root);
6706 }
6707
6708 #[test]
6711 fn canonical_skill_config_runtime_root_normalizes_equivalent_runtime_roots() {
6712 let runtime_root = make_temp_runtime_root("skill_config_equivalent_runtime_root");
6713 if runtime_root.exists() {
6714 let _ = fs::remove_dir_all(&runtime_root);
6715 }
6716 create_runtime_test_layout(&runtime_root);
6717
6718 let engine = LuaEngine::new(LuaEngineOptions {
6719 host_options: LuaRuntimeHostOptions::default(),
6720 pool_config: LuaVmPoolConfig {
6721 min_size: 1,
6722 max_size: 1,
6723 idle_ttl_secs: 60,
6724 },
6725 })
6726 .expect("create equivalent runtime root test engine");
6727
6728 let equivalent_root = runtime_root.join("nested").join("..").join("skills");
6729 let resolved_runtime_root = engine
6730 .canonical_skill_config_runtime_root(&[
6731 crate::host::options::RuntimeSkillRoot {
6732 name: "ROOT_CANONICAL".to_string(),
6733 skills_dir: runtime_root.join("skills"),
6734 },
6735 crate::host::options::RuntimeSkillRoot {
6736 name: "ROOT_EQUIVALENT".to_string(),
6737 skills_dir: equivalent_root,
6738 },
6739 ])
6740 .expect("equivalent runtime roots should resolve to one canonical root");
6741
6742 assert_eq!(resolved_runtime_root, runtime_root);
6743
6744 let _ = fs::remove_dir_all(&runtime_root);
6745 }
6746
6747 #[test]
6750 fn call_skill_reads_own_skill_config_namespace() {
6751 let runtime_root = make_temp_runtime_root("skill_config_call_skill");
6752 if runtime_root.exists() {
6753 let _ = fs::remove_dir_all(&runtime_root);
6754 }
6755 create_runtime_test_layout(&runtime_root);
6756 write_skill_config_test_skill(&runtime_root, "demo-skill");
6757
6758 let mut engine = LuaEngine::new(LuaEngineOptions {
6759 host_options: LuaRuntimeHostOptions::default(),
6760 pool_config: LuaVmPoolConfig {
6761 min_size: 1,
6762 max_size: 1,
6763 idle_ttl_secs: 60,
6764 },
6765 })
6766 .expect("create call_skill config test engine");
6767 engine
6768 .load_from_roots(&[crate::host::options::RuntimeSkillRoot {
6769 name: "ROOT".to_string(),
6770 skills_dir: runtime_root.join("skills"),
6771 }])
6772 .expect("load config test skill");
6773 engine
6774 .set_skill_config_value("demo-skill", "api_token", "sk-from-config")
6775 .expect("seed skill config value");
6776
6777 let result = engine
6778 .call_skill("demo-skill-ping", &json!({}), None)
6779 .expect("call skill with config");
6780 assert_eq!(result.content, "sk-from-config");
6781
6782 let _ = fs::remove_dir_all(&runtime_root);
6783 }
6784
6785 #[test]
6788 fn run_lua_config_api_requires_active_skill_context() {
6789 let engine = make_runtime_test_engine();
6790 let error = engine
6791 .run_lua("return vulcan.config.get('api_token')", &json!({}), None)
6792 .expect_err("run_lua config access should require active skill context");
6793 assert!(error.contains("vulcan.config.get requires one active skill context"));
6794 }
6795
6796 fn assert_vm_scope_is_clean(lua: &mlua::Lua) {
6799 let context = get_vulcan_context_table(lua).expect("get vulcan.context");
6800 let request: Table = context.get("request").expect("get request table");
6801 assert_eq!(request.raw_len(), 0);
6802 assert_eq!(request.pairs::<String, LuaValue>().count(), 0);
6803 assert!(matches!(
6804 context
6805 .get::<LuaValue>("client_info")
6806 .expect("get client_info"),
6807 LuaValue::Nil
6808 ));
6809 assert!(matches!(
6810 context
6811 .get::<LuaValue>("client_capabilities")
6812 .expect("get client_capabilities"),
6813 LuaValue::Table(_)
6814 ));
6815 assert!(matches!(
6816 context
6817 .get::<LuaValue>("client_budget")
6818 .expect("get client_budget"),
6819 LuaValue::Table(_)
6820 ));
6821 assert!(matches!(
6822 context
6823 .get::<LuaValue>("tool_config")
6824 .expect("get tool_config"),
6825 LuaValue::Table(_)
6826 ));
6827 assert!(matches!(
6828 context.get::<LuaValue>("skill_dir").expect("get skill_dir"),
6829 LuaValue::Nil
6830 ));
6831 assert!(matches!(
6832 context.get::<LuaValue>("entry_dir").expect("get entry_dir"),
6833 LuaValue::Nil
6834 ));
6835 assert!(matches!(
6836 context
6837 .get::<LuaValue>("entry_file")
6838 .expect("get entry_file"),
6839 LuaValue::Nil
6840 ));
6841
6842 let deps = get_vulcan_deps_table(lua).expect("get vulcan.deps");
6843 assert!(matches!(
6844 deps.get::<LuaValue>("tools_path").expect("get tools_path"),
6845 LuaValue::Nil
6846 ));
6847 assert!(matches!(
6848 deps.get::<LuaValue>("lua_path").expect("get lua_path"),
6849 LuaValue::Nil
6850 ));
6851 assert!(matches!(
6852 deps.get::<LuaValue>("ffi_path").expect("get ffi_path"),
6853 LuaValue::Nil
6854 ));
6855
6856 let internal = get_vulcan_runtime_internal_table(lua).expect("get runtime internal");
6857 assert!(matches!(
6858 internal
6859 .get::<LuaValue>("tool_name")
6860 .expect("get tool_name"),
6861 LuaValue::Nil
6862 ));
6863 assert!(matches!(
6864 internal
6865 .get::<LuaValue>("skill_name")
6866 .expect("get skill_name"),
6867 LuaValue::Nil
6868 ));
6869 assert!(
6870 !internal
6871 .get::<bool>("luaexec_active")
6872 .expect("get luaexec_active")
6873 );
6874 assert!(matches!(
6875 internal
6876 .get::<LuaValue>("luaexec_caller_tool_name")
6877 .expect("get luaexec_caller_tool_name"),
6878 LuaValue::Nil
6879 ));
6880
6881 let vulcan = get_vulcan_table(lua).expect("get vulcan");
6882 let lancedb: Table = vulcan.get("lancedb").expect("get lancedb");
6883 assert!(!lancedb.get::<bool>("enabled").expect("get lancedb enabled"));
6884 let sqlite: Table = vulcan.get("sqlite").expect("get sqlite");
6885 assert!(!sqlite.get::<bool>("enabled").expect("get sqlite enabled"));
6886 assert!(matches!(
6887 lua.globals()
6888 .get::<LuaValue>("__runlua_args")
6889 .expect("get __runlua_args"),
6890 LuaValue::Nil
6891 ));
6892 }
6893
6894 #[test]
6897 fn load_from_roots_rejects_explicit_skill_id_field() {
6898 let temp_root = std::env::temp_dir().join(format!(
6899 "vulcan_luaskills_reject_skill_id_test_{}",
6900 std::process::id()
6901 ));
6902 if temp_root.exists() {
6903 let _ = fs::remove_dir_all(&temp_root);
6904 }
6905 let skill_root = temp_root.join("skills");
6906 let skill_dir = skill_root.join("vulcan-codekit");
6907 fs::create_dir_all(skill_dir.join("runtime")).expect("create runtime dir");
6908 fs::write(
6909 skill_dir.join("skill.yaml"),
6910 "name: vulcan-codekit\nversion: 0.1.0\nskill_id: vulcan-codekit\nentries:\n - name: ast-tree\n lua_entry: runtime/test.lua\n lua_module: vulcan-codekit.ast-tree\n",
6911 )
6912 .expect("write skill yaml");
6913 fs::write(skill_dir.join("runtime").join("test.lua"), "return 'ok'\n")
6914 .expect("write runtime entry");
6915
6916 let mut engine = LuaEngine::new(LuaEngineOptions {
6917 host_options: LuaRuntimeHostOptions::default(),
6918 pool_config: LuaVmPoolConfig {
6919 min_size: 1,
6920 max_size: 1,
6921 idle_ttl_secs: 60,
6922 },
6923 })
6924 .expect("create engine");
6925
6926 let error = engine
6927 .load_from_roots(&[crate::host::options::RuntimeSkillRoot {
6928 name: "ROOT".to_string(),
6929 skills_dir: skill_root,
6930 }])
6931 .expect_err("explicit skill_id should be rejected");
6932 let rendered = error.to_string();
6933 assert!(rendered.contains("must not declare skill_id"));
6934
6935 let _ = fs::remove_dir_all(&temp_root);
6936 }
6937
6938 #[test]
6941 fn load_from_roots_skips_host_ignored_skill_before_resource_setup() {
6942 let temp_root = std::env::temp_dir().join(format!(
6943 "vulcan_luaskills_ignored_skill_test_{}",
6944 std::process::id()
6945 ));
6946 if temp_root.exists() {
6947 let _ = fs::remove_dir_all(&temp_root);
6948 }
6949 let skill_root = temp_root.join("skills");
6950 let skill_dir = skill_root.join("grpc-memory");
6951 fs::create_dir_all(skill_dir.join("runtime")).expect("create runtime dir");
6952 fs::write(
6953 skill_dir.join("skill.yaml"),
6954 "name: grpc-memory\nversion: 0.1.0\nenable: true\ndebug: false\nsqlite:\n enable: true\nlancedb:\n enable: true\nentries:\n - name: remember\n lua_entry: runtime/remember.lua\n lua_module: grpc-memory.remember\n",
6955 )
6956 .expect("write skill yaml");
6957 fs::write(
6958 skill_dir.join("runtime").join("remember.lua"),
6959 "return function(args)\n return 'unexpected-load'\nend\n",
6960 )
6961 .expect("write runtime entry");
6962
6963 let mut host_options = LuaRuntimeHostOptions::default();
6964 host_options.dependency_dir_name = "dependencies".to_string();
6965 host_options.state_dir_name = "state".to_string();
6966 host_options.database_dir_name = "databases".to_string();
6967 host_options.ignored_skill_ids = vec!["grpc-memory".to_string()];
6968 let mut engine = LuaEngine::new(LuaEngineOptions {
6969 host_options,
6970 pool_config: LuaVmPoolConfig {
6971 min_size: 1,
6972 max_size: 1,
6973 idle_ttl_secs: 60,
6974 },
6975 })
6976 .expect("create engine");
6977
6978 engine
6979 .load_from_roots(&[crate::host::options::RuntimeSkillRoot {
6980 name: "ROOT".to_string(),
6981 skills_dir: skill_root,
6982 }])
6983 .expect("ignored skill should not fail loading");
6984
6985 assert!(engine.skills.is_empty());
6986 assert!(engine.entry_registry.is_empty());
6987 assert!(!temp_root.join("dependencies").exists());
6988 assert!(!temp_root.join("state").exists());
6989 assert!(!temp_root.join("databases").exists());
6990
6991 let _ = fs::remove_dir_all(&temp_root);
6992 }
6993
6994 #[test]
6997 fn rebuild_entry_registry_appends_numeric_suffixes_for_collisions() {
6998 let mut skills = HashMap::new();
6999 skills.insert(
7000 "alpha".to_string(),
7001 make_loaded_skill("alpha", "foo-bar", "baz", "alpha_module"),
7002 );
7003 skills.insert(
7004 "beta".to_string(),
7005 make_loaded_skill("beta", "foo", "bar-baz", "beta_module"),
7006 );
7007 skills.insert(
7008 "gamma".to_string(),
7009 make_loaded_skill("gamma", "foo-bar", "baz", "gamma_module"),
7010 );
7011
7012 let mut engine = make_test_engine(skills);
7013 engine
7014 .rebuild_entry_registry()
7015 .expect("entry registry should rebuild successfully");
7016
7017 assert!(engine.entry_registry.contains_key("foo-bar-baz"));
7018 assert!(engine.entry_registry.contains_key("foo-bar-baz-2"));
7019 assert!(engine.entry_registry.contains_key("foo-bar-baz-3"));
7020
7021 let alpha_skill = engine
7022 .skills
7023 .get("alpha")
7024 .expect("alpha skill should exist");
7025 let beta_skill = engine.skills.get("beta").expect("beta skill should exist");
7026 let gamma_skill = engine
7027 .skills
7028 .get("gamma")
7029 .expect("gamma skill should exist");
7030
7031 assert_eq!(alpha_skill.resolved_tool_name("baz"), Some("foo-bar-baz"));
7032 assert_eq!(
7033 beta_skill.resolved_tool_name("bar-baz"),
7034 Some("foo-bar-baz-2")
7035 );
7036 assert_eq!(gamma_skill.resolved_tool_name("baz"), Some("foo-bar-baz-3"));
7037 }
7038
7039 #[test]
7042 fn rebuild_entry_registry_skips_host_reserved_names() {
7043 let mut skills = HashMap::new();
7044 skills.insert(
7045 "alpha".to_string(),
7046 make_loaded_skill("alpha", "vulcan", "help-list", "alpha_module"),
7047 );
7048
7049 let mut engine = make_test_engine(skills);
7050 Arc::get_mut(&mut engine.host_options)
7051 .expect("host options should be uniquely owned in test")
7052 .reserved_entry_names = vec!["vulcan-help-list".to_string()];
7053
7054 engine
7055 .rebuild_entry_registry()
7056 .expect("entry registry should rebuild successfully");
7057
7058 assert!(!engine.entry_registry.contains_key("vulcan-help-list"));
7059 assert!(engine.entry_registry.contains_key("vulcan-help-list-2"));
7060
7061 let alpha_skill = engine
7062 .skills
7063 .get("alpha")
7064 .expect("alpha skill should exist");
7065 assert_eq!(
7066 alpha_skill.resolved_tool_name("help-list"),
7067 Some("vulcan-help-list-2")
7068 );
7069 }
7070
7071 #[test]
7074 fn pooled_vm_scope_guard_cleans_state_after_early_exit() {
7075 let engine = make_runtime_test_engine();
7076 let scope_result: Result<(), String> = (|| {
7077 let mut lease = engine.acquire_vm()?;
7078 let _scope_guard =
7079 LuaVmRequestScopeGuard::new(&mut lease, engine.host_options.as_ref())?;
7080 let lua = _scope_guard.lua();
7081 LuaEngine::populate_vulcan_request_context(
7082 lua,
7083 Some(&crate::runtime_options::LuaInvocationContext::new(
7084 None,
7085 json!({"budget":"test"}),
7086 json!({"tool":"config"}),
7087 )),
7088 )?;
7089 populate_vulcan_internal_execution_context(
7090 lua,
7091 &VulcanInternalExecutionContext {
7092 tool_name: Some("test-tool".to_string()),
7093 skill_name: Some("test-skill".to_string()),
7094 luaexec_active: false,
7095 luaexec_caller_tool_name: None,
7096 },
7097 )?;
7098 let skill_dir = Path::new("D:/runtime-test-root/skills/test-skill");
7099 let entry_file = Path::new("D:/runtime-test-root/skills/test-skill/runtime/test.lua");
7100 populate_vulcan_file_context(lua, Some(skill_dir), Some(entry_file))?;
7101 populate_vulcan_dependency_context(
7102 lua,
7103 engine.host_options.as_ref(),
7104 Some(skill_dir),
7105 Some("test-skill"),
7106 )?;
7107 lua.globals()
7108 .set(
7109 "__runlua_args",
7110 json_to_lua_table(lua, &json!({"stale":"value"}))
7111 .expect("build runlua args table"),
7112 )
7113 .expect("set stale runlua args");
7114 Err("simulated setup failure".to_string())
7115 })();
7116 assert_eq!(
7117 scope_result.expect_err("scope should fail"),
7118 "simulated setup failure"
7119 );
7120
7121 let lease = engine.acquire_vm().expect("reacquire pooled vm");
7122 assert_vm_scope_is_clean(lease.lua());
7123 }
7124
7125 #[test]
7128 fn pooled_vm_scope_guard_discards_vm_when_entry_reset_fails() {
7129 let engine = make_runtime_test_engine();
7130 {
7131 let lease = engine.acquire_vm().expect("borrow pooled vm");
7132 let vulcan = get_vulcan_table(lease.lua()).expect("get vulcan");
7133 vulcan
7134 .set("context", LuaValue::Nil)
7135 .expect("break vulcan.context");
7136 }
7137
7138 let mut broken_lease = engine.acquire_vm().expect("reacquire broken pooled vm");
7139 let error =
7140 match LuaVmRequestScopeGuard::new(&mut broken_lease, engine.host_options.as_ref()) {
7141 Ok(_) => panic!("broken pooled vm should fail normalization"),
7142 Err(error) => error,
7143 };
7144 assert!(error.contains("vulcan.context"));
7145
7146 let mut fresh_lease = engine.acquire_vm().expect("borrow fresh pooled vm");
7147 let fresh_scope =
7148 LuaVmRequestScopeGuard::new(&mut fresh_lease, engine.host_options.as_ref())
7149 .expect("normalize fresh pooled vm");
7150 assert_vm_scope_is_clean(fresh_scope.lua());
7151 }
7152
7153 #[test]
7156 fn pooled_vm_scope_guard_discards_vm_when_exit_reset_fails() {
7157 let engine = make_runtime_test_engine();
7158 let mut lease = engine.acquire_vm().expect("borrow pooled vm");
7159 let scope_guard = LuaVmRequestScopeGuard::new(&mut lease, engine.host_options.as_ref())
7160 .expect("normalize pooled vm");
7161 let vulcan = get_vulcan_table(scope_guard.lua()).expect("get vulcan");
7162 vulcan
7163 .set("context", LuaValue::Nil)
7164 .expect("break vulcan.context");
7165 let error = scope_guard
7166 .finish()
7167 .expect_err("cleanup should fail after context corruption");
7168 assert!(error.contains("vulcan.context"));
7169
7170 let mut fresh_lease = engine.acquire_vm().expect("borrow fresh pooled vm");
7171 let fresh_scope =
7172 LuaVmRequestScopeGuard::new(&mut fresh_lease, engine.host_options.as_ref())
7173 .expect("normalize fresh pooled vm");
7174 assert_vm_scope_is_clean(fresh_scope.lua());
7175 }
7176
7177 #[test]
7180 fn run_lua_clears_args_after_success() {
7181 let engine = make_runtime_test_engine();
7182 let result = engine
7183 .run_lua("return args.value", &json!({"value":"hello"}), None)
7184 .expect("run_lua should succeed");
7185 assert_eq!(result, json!("hello"));
7186
7187 let lease = engine.acquire_vm().expect("reacquire pooled vm");
7188 assert_vm_scope_is_clean(lease.lua());
7189 }
7190
7191 #[test]
7194 fn execute_runlua_request_inline_reuses_dedicated_pool() {
7195 let engine = make_runtime_test_engine();
7196 assert_eq!(engine.runlua_pool.total_count(), 0);
7197
7198 let first = engine
7199 .execute_runlua_request_json_inline(r#"{"code":"return 1"}"#)
7200 .expect("first inline runlua should succeed");
7201 assert!(!first.trim().is_empty());
7202 assert_eq!(engine.runlua_pool.total_count(), 1);
7203
7204 let second = engine
7205 .execute_runlua_request_json_inline(r#"{"code":"return 2"}"#)
7206 .expect("second inline runlua should succeed");
7207 assert!(!second.trim().is_empty());
7208 assert_eq!(engine.runlua_pool.total_count(), 1);
7209 }
7210
7211 #[test]
7214 fn run_lua_clears_args_after_failure() {
7215 let engine = make_runtime_test_engine();
7216 let error = engine
7217 .run_lua("error('boom')", &json!({"value":"hello"}), None)
7218 .expect_err("run_lua should fail");
7219 assert!(error.contains("Lua run_lua error"));
7220
7221 let lease = engine.acquire_vm().expect("reacquire pooled vm");
7222 assert_vm_scope_is_clean(lease.lua());
7223 }
7224
7225 #[test]
7228 fn vulcan_call_restores_outer_context_after_nested_failure() {
7229 let temp_root = std::env::temp_dir().join(format!(
7230 "vulcan_luaskills_nested_call_restore_test_{}",
7231 std::process::id()
7232 ));
7233 if temp_root.exists() {
7234 let _ = fs::remove_dir_all(&temp_root);
7235 }
7236 let skill_root = temp_root.join("skills");
7237 let skill_dir = skill_root.join("test-skill");
7238 fs::create_dir_all(skill_dir.join("runtime")).expect("create runtime dir");
7239 fs::write(
7240 skill_dir.join("skill.yaml"),
7241 "name: test-skill\nversion: 0.1.0\nenable: true\ndebug: false\nentries:\n - name: outer\n lua_entry: runtime/outer.lua\n lua_module: test-skill.outer\n - name: nested\n lua_entry: runtime/nested.lua\n lua_module: test-skill.nested\n",
7242 )
7243 .expect("write skill yaml");
7244 fs::write(
7245 skill_dir.join("runtime").join("outer.lua"),
7246 "return function(args)\n local ok, err = pcall(vulcan.call, \"test-skill-nested\", {})\n if ok then\n return \"nested-call-unexpected-success\"\n end\n local tool_name = (vulcan.runtime and vulcan.runtime.internal and vulcan.runtime.internal.tool_name) or \"tool-nil\"\n local entry_file = (vulcan.context and vulcan.context.entry_file) or \"entry-nil\"\n local deps_path = (vulcan.deps and vulcan.deps.lua_path) or \"deps-nil\"\n return tool_name .. \"|\" .. entry_file .. \"|\" .. deps_path\nend\n",
7247 )
7248 .expect("write outer runtime entry");
7249 fs::write(
7250 skill_dir.join("runtime").join("nested.lua"),
7251 "return function(args)\n vulcan.runtime = nil\n vulcan.context = nil\n vulcan.deps = nil\n error(\"boom\")\nend\n",
7252 )
7253 .expect("write nested runtime entry");
7254
7255 let mut engine = LuaEngine::new(LuaEngineOptions {
7256 host_options: LuaRuntimeHostOptions::default(),
7257 pool_config: LuaVmPoolConfig {
7258 min_size: 1,
7259 max_size: 1,
7260 idle_ttl_secs: 60,
7261 },
7262 })
7263 .expect("create engine");
7264 engine
7265 .load_from_roots(&[crate::host::options::RuntimeSkillRoot {
7266 name: "ROOT".to_string(),
7267 skills_dir: skill_root.clone(),
7268 }])
7269 .expect("load nested-call test skill");
7270
7271 let result = engine
7272 .call_skill("test-skill-outer", &json!({}), None)
7273 .expect("outer skill should succeed after nested failure");
7274 assert!(result.content.starts_with("test-skill-outer|"));
7275 assert!(result.content.contains("outer.lua"));
7276 assert!(!result.content.contains("|entry-nil|"));
7277 assert!(!result.content.ends_with("|deps-nil"));
7278 assert!(result.content.contains("test-skill"));
7279
7280 let _ = fs::remove_dir_all(&temp_root);
7281 }
7282}