1use anyhow::{bail, Context, Result};
49use async_trait::async_trait;
50use mlua::prelude::*;
51use serde::Serialize;
52use std::path::{Path, PathBuf};
53use std::sync::{
54 atomic::{AtomicBool, Ordering},
55 Arc,
56};
57use std::time::{Duration, Instant};
58
59use crate::config::{Config, ScriptToolConfig};
60use crate::get::{get_document, DocumentResponse};
61use crate::lua_runtime::{
62 json_value_to_lua, lua_value_to_json, register_all_host_apis, toml_table_to_lua,
63};
64use crate::search::{search_documents, SearchResultItem};
65use crate::sources::{get_sources, SourceStatus};
66use crate::traits::{Tool, ToolContext};
67
68#[derive(Debug, Clone)]
78pub struct ToolDefinition {
79 pub name: String,
81 pub description: String,
83 pub parameters_schema: serde_json::Value,
85 pub script_path: PathBuf,
87 pub script_source: String,
89 pub config: toml::Table,
91 pub timeout: u64,
93}
94
95#[derive(Debug, Clone, Serialize)]
97pub struct ToolInfo {
98 pub name: String,
100 pub description: String,
102 pub builtin: bool,
104 pub parameters: serde_json::Value,
106}
107
108pub struct LuaToolAdapter {
117 definition: ToolDefinition,
119 config: Arc<Config>,
121}
122
123impl LuaToolAdapter {
124 pub fn new(definition: ToolDefinition, config: Arc<Config>) -> Self {
126 Self { definition, config }
127 }
128}
129
130#[async_trait]
131impl Tool for LuaToolAdapter {
132 fn name(&self) -> &str {
133 &self.definition.name
134 }
135
136 fn description(&self) -> &str {
137 &self.definition.description
138 }
139
140 fn parameters_schema(&self) -> serde_json::Value {
141 self.definition.parameters_schema.clone()
142 }
143
144 async fn execute(
145 &self,
146 params: serde_json::Value,
147 _ctx: &ToolContext,
148 ) -> Result<serde_json::Value> {
149 execute_tool(&self.definition, params, &self.config).await
152 }
153}
154
155pub fn load_tool_definitions(config: &Config) -> Result<Vec<ToolDefinition>> {
167 let mut tools = Vec::new();
168
169 for (name, tool_config) in &config.tools.script {
170 let tool_def = load_single_tool(name, tool_config)
171 .with_context(|| format!("Failed to load tool script '{}'", name))?;
172 tools.push(tool_def);
173 }
174
175 Ok(tools)
176}
177
178pub fn load_single_tool(name: &str, tool_config: &ScriptToolConfig) -> Result<ToolDefinition> {
180 let script_src = std::fs::read_to_string(&tool_config.path)
181 .with_context(|| format!("Failed to read tool script: {}", tool_config.path.display()))?;
182
183 let lua = Lua::new();
185 lua.load(&script_src)
186 .set_name(tool_config.path.to_string_lossy())
187 .exec()
188 .map_err(|e| {
189 anyhow::anyhow!(
190 "Failed to execute tool script {}: {}",
191 tool_config.path.display(),
192 e
193 )
194 })?;
195
196 let tool_table: LuaTable = lua
197 .globals()
198 .get::<LuaTable>("tool")
199 .map_err(|e| anyhow::anyhow!("Script must define a global 'tool' table: {}", e))?;
200
201 let description: String = tool_table
202 .get::<String>("description")
203 .unwrap_or_else(|_| format!("Lua tool: {}", name));
204
205 let params_table: LuaTable = tool_table
206 .get::<LuaTable>("parameters")
207 .unwrap_or_else(|_| lua.create_table().expect("create_table"));
208
209 let schema = lua_params_to_json_schema(¶ms_table)?;
210
211 Ok(ToolDefinition {
212 name: name.to_string(),
213 description,
214 parameters_schema: schema,
215 script_path: tool_config.path.clone(),
216 script_source: script_src,
217 config: tool_config.extra.clone(),
218 timeout: tool_config.timeout,
219 })
220}
221
222fn lua_params_to_json_schema(params: &LuaTable) -> Result<serde_json::Value> {
248 let mut properties = serde_json::Map::new();
249 let mut required = Vec::new();
250
251 let len = params.raw_len();
252 for i in 1..=len {
253 let param: LuaTable = params
254 .raw_get(i)
255 .map_err(|e| anyhow::anyhow!("Invalid parameter at index {}: {}", i, e))?;
256
257 let param_name: String = param
258 .get::<String>("name")
259 .map_err(|e| anyhow::anyhow!("Parameter at index {} missing 'name': {}", i, e))?;
260
261 let param_type: String = param
262 .get::<String>("type")
263 .unwrap_or_else(|_| "string".to_string());
264
265 let mut prop = serde_json::Map::new();
266 prop.insert("type".to_string(), serde_json::json!(param_type));
267
268 if let Ok(desc) = param.get::<String>("description") {
269 prop.insert("description".to_string(), serde_json::json!(desc));
270 }
271
272 if let Ok(default_val) = param.get::<LuaValue>("default") {
273 if !matches!(default_val, LuaValue::Nil) {
274 let json_default = lua_value_to_json(default_val)
275 .map_err(|e| anyhow::anyhow!("Invalid default for '{}': {}", param_name, e))?;
276 prop.insert("default".to_string(), json_default);
277 }
278 }
279
280 if let Ok(enum_table) = param.get::<LuaTable>("enum") {
281 let mut enum_values = Vec::new();
282 let enum_len = enum_table.raw_len();
283 for j in 1..=enum_len {
284 let val: LuaValue = enum_table.raw_get(j)?;
285 let json_val = lua_value_to_json(val).map_err(|e| {
286 anyhow::anyhow!("Invalid enum value for '{}': {}", param_name, e)
287 })?;
288 enum_values.push(json_val);
289 }
290 prop.insert("enum".to_string(), serde_json::Value::Array(enum_values));
291 }
292
293 let is_required = param.get::<bool>("required").unwrap_or(false);
294 if is_required {
295 required.push(serde_json::json!(param_name));
296 }
297
298 properties.insert(param_name, serde_json::Value::Object(prop));
299 }
300
301 let mut schema = serde_json::Map::new();
302 schema.insert("type".to_string(), serde_json::json!("object"));
303 schema.insert(
304 "properties".to_string(),
305 serde_json::Value::Object(properties),
306 );
307 if !required.is_empty() {
308 schema.insert("required".to_string(), serde_json::Value::Array(required));
309 }
310
311 Ok(serde_json::Value::Object(schema))
312}
313
314pub fn validate_params(
324 schema: &serde_json::Value,
325 params: &serde_json::Value,
326) -> Result<serde_json::Value> {
327 let params_obj = params
328 .as_object()
329 .unwrap_or(&serde_json::Map::new())
330 .clone();
331
332 let properties = schema
333 .get("properties")
334 .and_then(|p| p.as_object())
335 .cloned()
336 .unwrap_or_default();
337
338 let required: Vec<String> = schema
339 .get("required")
340 .and_then(|r| r.as_array())
341 .map(|arr| {
342 arr.iter()
343 .filter_map(|v| v.as_str().map(|s| s.to_string()))
344 .collect()
345 })
346 .unwrap_or_default();
347
348 let mut result = params_obj.clone();
349
350 for req_field in &required {
352 if !params_obj.contains_key(req_field) {
353 bail!("missing required parameter: {}", req_field);
354 }
355 }
356
357 for (prop_name, prop_schema) in &properties {
359 if let Some(value) = params_obj.get(prop_name) {
360 if let Some(expected_type) = prop_schema.get("type").and_then(|t| t.as_str()) {
362 let type_ok = match expected_type {
363 "string" => value.is_string(),
364 "integer" => value.is_i64() || value.is_u64(),
365 "number" => value.is_number(),
366 "boolean" => value.is_boolean(),
367 "array" => value.is_array(),
368 "object" => value.is_object(),
369 _ => true,
370 };
371 if !type_ok {
372 bail!(
373 "parameter '{}' must be of type '{}', got {}",
374 prop_name,
375 expected_type,
376 json_type_name(value)
377 );
378 }
379 }
380
381 if let Some(enum_values) = prop_schema.get("enum").and_then(|e| e.as_array()) {
383 if !enum_values.contains(value) {
384 let allowed: Vec<String> = enum_values.iter().map(|v| v.to_string()).collect();
385 bail!(
386 "parameter '{}' must be one of [{}], got {}",
387 prop_name,
388 allowed.join(", "),
389 value
390 );
391 }
392 }
393 } else {
394 if let Some(default) = prop_schema.get("default") {
396 result.insert(prop_name.clone(), default.clone());
397 }
398 }
399 }
400
401 Ok(serde_json::Value::Object(result))
402}
403
404fn json_type_name(value: &serde_json::Value) -> &'static str {
406 match value {
407 serde_json::Value::Null => "null",
408 serde_json::Value::Bool(_) => "boolean",
409 serde_json::Value::Number(_) => "number",
410 serde_json::Value::String(_) => "string",
411 serde_json::Value::Array(_) => "array",
412 serde_json::Value::Object(_) => "object",
413 }
414}
415
416pub async fn execute_tool(
435 tool: &ToolDefinition,
436 params: serde_json::Value,
437 app_config: &Config,
438) -> Result<serde_json::Value> {
439 let tool = tool.clone();
440 let config = app_config.clone();
441 let cancelled = Arc::new(AtomicBool::new(false));
442 let worker_cancelled = cancelled.clone();
443 let _cancel_on_drop = CancelOnDrop(cancelled);
444
445 tokio::task::spawn_blocking(move || run_lua_tool(&tool, params, &config, worker_cancelled))
446 .await
447 .context("Lua tool task panicked")?
448}
449
450struct CancelOnDrop(Arc<AtomicBool>);
453
454impl Drop for CancelOnDrop {
455 fn drop(&mut self) {
456 self.0.store(true, Ordering::Release);
457 }
458}
459
460fn run_lua_tool(
462 tool: &ToolDefinition,
463 params: serde_json::Value,
464 config: &Config,
465 cancelled: Arc<AtomicBool>,
466) -> Result<serde_json::Value> {
467 let script_dir = tool
468 .script_path
469 .parent()
470 .unwrap_or(Path::new("."))
471 .to_path_buf();
472
473 let lua = Lua::new();
474
475 let timeout_secs = tool.timeout;
477 let deadline = Instant::now() + Duration::from_secs(timeout_secs);
478 lua.set_hook(
479 mlua::HookTriggers::new().every_nth_instruction(10_000),
480 move |_lua, _debug| {
481 if cancelled.load(Ordering::Acquire) {
482 Err(mlua::Error::RuntimeError("tool cancelled".into()))
483 } else if Instant::now() > deadline {
484 Err(mlua::Error::RuntimeError(format!(
485 "tool timed out after {} seconds",
486 timeout_secs
487 )))
488 } else {
489 Ok(mlua::VmState::Continue)
490 }
491 },
492 );
493
494 let log_name = format!("tool:{}", tool.name);
496 register_all_host_apis(&lua, &log_name, &script_dir)?;
497
498 register_context_bridge(&lua, config, &tool.config)?;
500
501 lua.load(&tool.script_source)
503 .set_name(tool.script_path.to_string_lossy())
504 .exec()
505 .map_err(|e| {
506 anyhow::anyhow!(
507 "Failed to execute tool script {}: {}",
508 tool.script_path.display(),
509 e
510 )
511 })?;
512
513 let tool_table: LuaTable = lua
515 .globals()
516 .get::<LuaTable>("tool")
517 .map_err(|e| anyhow::anyhow!("Script must define a global 'tool' table: {}", e))?;
518
519 let execute: LuaFunction = tool_table
520 .get::<LuaFunction>("execute")
521 .map_err(|e| anyhow::anyhow!("tool.execute function not defined: {}", e))?;
522
523 let params_lua = json_value_to_lua(&lua, ¶ms)?;
525
526 let context: LuaTable = lua
528 .globals()
529 .get::<LuaTable>("context")
530 .map_err(|e| anyhow::anyhow!("context table missing: {}", e))?;
531
532 let result: LuaValue = execute
534 .call::<LuaValue>((params_lua, context))
535 .map_err(|e| {
536 anyhow::anyhow!(
537 "tool.execute() failed in '{}': {}",
538 tool.script_path.display(),
539 e
540 )
541 })?;
542
543 lua_value_to_json(result)
545 .map_err(|e| anyhow::anyhow!("Failed to convert tool result to JSON: {}", e))
546}
547
548fn register_context_bridge(lua: &Lua, config: &Config, tool_config: &toml::Table) -> LuaResult<()> {
558 let ctx = lua.create_table()?;
559
560 let config_lua = toml_table_to_lua(lua, tool_config)?;
562 ctx.set("config", config_lua)?;
563
564 let cfg = config.clone();
566 ctx.set(
567 "search",
568 lua.create_function(move |lua, (query, opts): (String, Option<LuaTable>)| {
569 let mode = opts
570 .as_ref()
571 .and_then(|o| o.get::<String>("mode").ok())
572 .unwrap_or_else(|| "keyword".to_string());
573 let limit = opts
574 .as_ref()
575 .and_then(|o| o.get::<i64>("limit").ok())
576 .unwrap_or(12);
577 let source = opts.as_ref().and_then(|o| o.get::<String>("source").ok());
578
579 let handle = tokio::runtime::Handle::current();
580 let results = handle
581 .block_on(async {
582 search_documents(
583 &cfg,
584 &query,
585 &mode,
586 source.as_deref(),
587 None,
588 Some(limit),
589 false,
590 )
591 .await
592 })
593 .map_err(mlua::Error::external)?;
594
595 search_results_to_lua(lua, &results)
596 })?,
597 )?;
598
599 let cfg = config.clone();
601 ctx.set(
602 "get",
603 lua.create_function(move |lua, id: String| {
604 let handle = tokio::runtime::Handle::current();
605 let doc = handle
606 .block_on(async { get_document(&cfg, &id).await })
607 .map_err(mlua::Error::external)?;
608
609 doc_response_to_lua(lua, &doc)
610 })?,
611 )?;
612
613 let cfg = config.clone();
615 ctx.set(
616 "sources",
617 lua.create_function(move |lua, ()| {
618 let sources = get_sources(&cfg);
619 sources_to_lua(lua, &sources)
620 })?,
621 )?;
622
623 lua.globals().set("context", ctx)?;
624 Ok(())
625}
626
627fn search_results_to_lua(lua: &Lua, results: &[SearchResultItem]) -> LuaResult<LuaTable> {
629 let table = lua.create_table()?;
630 for (i, item) in results.iter().enumerate() {
631 let row = lua.create_table()?;
632 row.set("id", item.id.as_str())?;
633 row.set("score", item.score)?;
634 row.set("source", item.source.as_str())?;
635 row.set("source_id", item.source_id.as_str())?;
636 row.set("updated_at", item.updated_at.as_str())?;
637 row.set("snippet", item.snippet.as_str())?;
638 if let Some(ref title) = item.title {
639 row.set("title", title.as_str())?;
640 }
641 if let Some(ref url) = item.source_url {
642 row.set("source_url", url.as_str())?;
643 }
644 table.set(i as i64 + 1, row)?;
645 }
646 Ok(table)
647}
648
649fn doc_response_to_lua(lua: &Lua, doc: &DocumentResponse) -> LuaResult<LuaTable> {
651 let table = lua.create_table()?;
652 table.set("id", doc.id.as_str())?;
653 table.set("source", doc.source.as_str())?;
654 table.set("source_id", doc.source_id.as_str())?;
655 table.set("content_type", doc.content_type.as_str())?;
656 table.set("body", doc.body.as_str())?;
657 table.set("created_at", doc.created_at.as_str())?;
658 table.set("updated_at", doc.updated_at.as_str())?;
659 if let Some(ref title) = doc.title {
660 table.set("title", title.as_str())?;
661 }
662 if let Some(ref author) = doc.author {
663 table.set("author", author.as_str())?;
664 }
665 if let Some(ref url) = doc.source_url {
666 table.set("source_url", url.as_str())?;
667 }
668
669 let chunks_table = lua.create_table()?;
671 for (i, chunk) in doc.chunks.iter().enumerate() {
672 let c = lua.create_table()?;
673 c.set("index", chunk.index)?;
674 c.set("text", chunk.text.as_str())?;
675 chunks_table.set(i as i64 + 1, c)?;
676 }
677 table.set("chunks", chunks_table)?;
678
679 Ok(table)
680}
681
682fn sources_to_lua(lua: &Lua, sources: &[SourceStatus]) -> LuaResult<LuaTable> {
684 let table = lua.create_table()?;
685 for (i, s) in sources.iter().enumerate() {
686 let row = lua.create_table()?;
687 row.set("name", s.name.as_str())?;
688 row.set("configured", s.configured)?;
689 row.set("healthy", s.healthy)?;
690 if let Some(ref notes) = s.notes {
691 row.set("notes", notes.as_str())?;
692 }
693 table.set(i as i64 + 1, row)?;
694 }
695 Ok(table)
696}
697
698pub fn build_tool_list(lua_tools: &[ToolDefinition]) -> Vec<ToolInfo> {
704 let mut tools = Vec::new();
705
706 tools.push(ToolInfo {
708 name: "search".to_string(),
709 description: "Search the knowledge base".to_string(),
710 builtin: true,
711 parameters: serde_json::json!({
712 "type": "object",
713 "properties": {
714 "query": { "type": "string", "description": "Search query" },
715 "mode": { "type": "string", "enum": ["keyword", "semantic", "hybrid"], "default": "keyword" },
716 "limit": { "type": "integer", "description": "Max results", "default": 12 },
717 "filters": {
718 "type": "object",
719 "properties": {
720 "source": { "type": "string", "description": "Filter by connector source" },
721 "since": { "type": "string", "description": "Only results updated after this date (YYYY-MM-DD)" }
722 }
723 }
724 },
725 "required": ["query"]
726 }),
727 });
728
729 tools.push(ToolInfo {
730 name: "get".to_string(),
731 description: "Retrieve a document by UUID".to_string(),
732 builtin: true,
733 parameters: serde_json::json!({
734 "type": "object",
735 "properties": {
736 "id": { "type": "string", "description": "Document UUID" }
737 },
738 "required": ["id"]
739 }),
740 });
741
742 tools.push(ToolInfo {
743 name: "sources".to_string(),
744 description: "List connector configuration and health status".to_string(),
745 builtin: true,
746 parameters: serde_json::json!({
747 "type": "object",
748 "properties": {}
749 }),
750 });
751
752 for tool in lua_tools {
754 tools.push(ToolInfo {
755 name: tool.name.clone(),
756 description: tool.description.clone(),
757 builtin: false,
758 parameters: tool.parameters_schema.clone(),
759 });
760 }
761
762 tools
763}
764
765pub fn scaffold_tool(name: &str) -> Result<()> {
774 let dir = Path::new("tools");
775 std::fs::create_dir_all(dir)?;
776
777 let filename = format!("{}.lua", name.replace('_', "-"));
778 let path = dir.join(&filename);
779
780 if path.exists() {
781 bail!("Tool script already exists: {}", path.display());
782 }
783
784 let template = format!(
785 r#"--[[
786 Context Harness Tool: {name}
787
788 Configuration (add to ctx.toml):
789
790 [tools.script.{name}]
791 path = "tools/{filename}"
792 timeout = 30
793 # api_key = "${{{name_upper}_API_KEY}}"
794
795 Test:
796 ctx tool test tools/{filename} --param key=value
797]]
798
799tool = {{
800 name = "{name}",
801 version = "0.1.0",
802 description = "TODO: describe what this tool does",
803 parameters = {{
804 {{
805 name = "query",
806 type = "string",
807 required = true,
808 description = "Input query",
809 }},
810 }},
811}}
812
813--- Execute the tool with the given parameters and context.
814--- @param params table Validated parameters from the caller
815--- @param context table Bridge to Context Harness (search, get, sources, config)
816--- @return table Result to be serialized as JSON
817function tool.execute(params, context)
818 -- Example: search the knowledge base
819 -- local results = context.search(params.query, {{ mode = "hybrid", limit = 5 }})
820
821 -- Example: access tool config from ctx.toml
822 -- local api_key = context.config.api_key
823
824 return {{
825 success = true,
826 message = "TODO: implement tool logic",
827 query = params.query,
828 }}
829end
830"#,
831 name = name,
832 filename = filename,
833 name_upper = name.to_uppercase().replace('-', "_"),
834 );
835
836 std::fs::write(&path, template)?;
837 println!("Created tool: {}", path.display());
838 println!();
839 println!("Add to your ctx.toml:");
840 println!();
841 println!(" [tools.script.{}]", name);
842 println!(" path = \"tools/{}\"", filename);
843 println!();
844 println!("Then test:");
845 println!();
846 println!(" ctx tool test tools/{} --param query=\"hello\"", filename);
847
848 Ok(())
849}
850
851pub async fn test_tool(
856 path: &Path,
857 params: Vec<(String, String)>,
858 config: &Config,
859 source: Option<&str>,
860) -> Result<()> {
861 let script_src = std::fs::read_to_string(path)
862 .with_context(|| format!("Failed to read tool script: {}", path.display()))?;
863
864 let tool_config_extra = if let Some(name) = source {
865 config
866 .tools
867 .script
868 .get(name)
869 .map(|sc| sc.extra.clone())
870 .unwrap_or_default()
871 } else {
872 toml::Table::new()
873 };
874
875 let timeout = source
876 .and_then(|name| config.tools.script.get(name))
877 .map(|sc| sc.timeout)
878 .unwrap_or(30);
879
880 let name = source.unwrap_or("test").to_string();
881 println!("Testing tool: {} ({})", name, path.display());
882
883 let mut params_json = serde_json::Map::new();
885 for (k, v) in ¶ms {
886 let json_val = serde_json::from_str::<serde_json::Value>(v)
888 .unwrap_or_else(|_| serde_json::Value::String(v.clone()));
889 params_json.insert(k.clone(), json_val);
890 }
891 let params_value = serde_json::Value::Object(params_json);
892
893 let tool_def = ToolDefinition {
894 name: name.clone(),
895 description: String::new(),
896 parameters_schema: serde_json::json!({"type": "object", "properties": {}}),
897 script_path: path.to_path_buf(),
898 script_source: script_src,
899 config: tool_config_extra,
900 timeout,
901 };
902
903 println!(" ✓ Script loaded");
904
905 let result = execute_tool(&tool_def, params_value, config).await?;
906
907 println!(" ✓ Execution completed");
908 println!();
909 println!("Result:");
910
911 let pretty = serde_json::to_string_pretty(&result)?;
912 for line in pretty.lines() {
913 println!(" {}", line);
914 }
915
916 Ok(())
917}
918
919pub fn list_tools(config: &Config) -> Result<()> {
921 let tool_defs = load_tool_definitions(config)?;
922 let tools = build_tool_list(&tool_defs);
923
924 if tools.is_empty() {
925 println!("No tools configured.");
926 return Ok(());
927 }
928
929 println!("{:<24} {:<8} DESCRIPTION", "TOOL", "TYPE");
930 for t in &tools {
931 let type_str = if t.builtin { "built-in" } else { "lua" };
932 println!("{:<24} {:<8} {}", t.name, type_str, t.description);
933 }
934
935 Ok(())
936}
937
938#[cfg(test)]
939mod cancellation_tests {
940 use super::*;
941 use std::sync::mpsc;
942
943 #[test]
944 fn cancellation_interrupts_cpu_bound_lua_before_its_timeout() {
945 let temp = tempfile::TempDir::new().unwrap();
946 let tool = ToolDefinition {
947 name: "loop".into(),
948 description: "loop forever".into(),
949 parameters_schema: serde_json::json!({
950 "type": "object",
951 "properties": {},
952 "additionalProperties": false
953 }),
954 script_path: temp.path().join("loop.lua"),
955 script_source: "tool = {}; function tool.execute() while true do end end".into(),
956 config: toml::Table::new(),
957 timeout: 30,
958 };
959 let config = Config::minimal();
960 let cancelled = Arc::new(AtomicBool::new(false));
961 let worker_cancelled = cancelled.clone();
962 let (sender, receiver) = mpsc::channel();
963 std::thread::spawn(move || {
964 let result = run_lua_tool(&tool, serde_json::json!({}), &config, worker_cancelled);
965 sender.send(result).unwrap();
966 });
967
968 std::thread::sleep(Duration::from_millis(25));
969 cancelled.store(true, Ordering::Release);
970 let error = receiver
971 .recv_timeout(Duration::from_secs(2))
972 .expect("Lua worker ignored cancellation")
973 .unwrap_err();
974 assert!(format!("{error:#}").contains("tool cancelled"));
975 }
976}