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