Skip to main content

context_harness/
tool_script.rs

1//! Lua MCP tool extensions.
2//!
3//! Loads `.lua` tool scripts at server startup, extracts their parameter schemas,
4//! and provides execution with a context bridge back into the Rust core
5//! (search, get, sources).
6//!
7//! # Architecture
8//!
9//! Tool scripts follow the same sandboxed Lua VM model as connectors
10//! ([`crate::connector_script`]), reusing all host APIs from
11//! [`crate::lua_runtime`]. In addition, tools receive a `context` table with:
12//!
13//! - `context.search(query, opts?)` — search the knowledge base
14//! - `context.get(id)` — retrieve a document by UUID
15//! - `context.sources()` — list connector status
16//! - `context.config` — tool-specific configuration from `ctx.toml`
17//!
18//! # Script Interface
19//!
20//! Every tool script defines a global `tool` table:
21//!
22//! ```lua
23//! tool = {
24//!     name = "my_tool",
25//!     description = "Does something useful",
26//!     parameters = {
27//!         { name = "query", type = "string", required = true, description = "Search query" },
28//!     },
29//! }
30//!
31//! function tool.execute(params, context)
32//!     local results = context.search(params.query)
33//!     return { results = results }
34//! end
35//! ```
36//!
37//! # Configuration
38//!
39//! ```toml
40//! [tools.script.my_tool]
41//! path = "tools/my-tool.lua"
42//! timeout = 30
43//! api_key = "${MY_API_KEY}"
44//! ```
45//!
46//! See `docs/LUA_TOOLS.md` for the full specification.
47
48use 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// ═══════════════════════════════════════════════════════════════════════
69// Types
70// ═══════════════════════════════════════════════════════════════════════
71
72/// Metadata extracted from a loaded Lua tool script.
73///
74/// Created at server startup by [`load_tool_definitions`]. Contains
75/// everything needed to register the tool with the HTTP server and
76/// execute it when called.
77#[derive(Debug, Clone)]
78pub struct ToolDefinition {
79    /// Tool identifier (matches the config key in `[tools.script.<name>]`).
80    pub name: String,
81    /// One-line description for agent discovery.
82    pub description: String,
83    /// OpenAI function-calling JSON Schema for the tool's parameters.
84    pub parameters_schema: serde_json::Value,
85    /// Path to the `.lua` script file.
86    pub script_path: PathBuf,
87    /// Raw Lua source code (cached to avoid re-reading on every call).
88    pub script_source: String,
89    /// Tool-specific config keys from `ctx.toml` (passed as `context.config`).
90    pub config: toml::Table,
91    /// Maximum execution time in seconds.
92    pub timeout: u64,
93}
94
95/// Serializable tool info for the `/tools/list` endpoint.
96#[derive(Debug, Clone, Serialize)]
97pub struct ToolInfo {
98    /// Tool name.
99    pub name: String,
100    /// Tool description.
101    pub description: String,
102    /// Whether this is a built-in tool (`true`) or a Lua tool (`false`).
103    pub builtin: bool,
104    /// OpenAI function-calling JSON Schema.
105    pub parameters: serde_json::Value,
106}
107
108// ═══════════════════════════════════════════════════════════════════════
109// Tool trait adapter
110// ═══════════════════════════════════════════════════════════════════════
111
112/// Adapter that wraps a Lua [`ToolDefinition`] as a [`Tool`] trait object.
113///
114/// This allows Lua tools to participate in the unified tool dispatch
115/// alongside built-in and custom Rust tools.
116pub struct LuaToolAdapter {
117    /// The underlying Lua tool definition.
118    definition: ToolDefinition,
119    /// Application config needed for the Lua context bridge.
120    config: Arc<Config>,
121}
122
123impl LuaToolAdapter {
124    /// Create a new adapter wrapping a Lua tool definition.
125    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        // Delegate to the existing Lua execution path, which has its own
150        // context bridge (search, get, sources) built into the Lua VM.
151        execute_tool(&self.definition, params, &self.config).await
152    }
153}
154
155// ═══════════════════════════════════════════════════════════════════════
156// Loading
157// ═══════════════════════════════════════════════════════════════════════
158
159/// Load all tool scripts from config and extract their definitions.
160///
161/// For each `[tools.script.<name>]` entry, reads the script file, creates
162/// a temporary Lua VM to extract the `tool` table metadata, and converts
163/// the parameter declarations to OpenAI JSON Schema.
164///
165/// Called once at server startup.
166pub 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
178/// Load a single tool script and extract its definition.
179pub 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    // Create a temporary Lua VM just to extract metadata
184    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(&params_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
222// ═══════════════════════════════════════════════════════════════════════
223// Schema Conversion
224// ═══════════════════════════════════════════════════════════════════════
225
226/// Convert Lua parameter declarations to OpenAI function-calling JSON Schema.
227///
228/// Input format (Lua array of tables):
229/// ```lua
230/// {
231///     { name = "title", type = "string", required = true, description = "Ticket title" },
232///     { name = "priority", type = "string", enum = { "low", "medium", "high" } },
233/// }
234/// ```
235///
236/// Output format:
237/// ```json
238/// {
239///     "type": "object",
240///     "properties": {
241///         "title": { "type": "string", "description": "Ticket title" },
242///         "priority": { "type": "string", "enum": ["low", "medium", "high"] }
243///     },
244///     "required": ["title"]
245/// }
246/// ```
247fn 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
314// ═══════════════════════════════════════════════════════════════════════
315// Parameter Validation
316// ═══════════════════════════════════════════════════════════════════════
317
318/// Validate incoming JSON parameters against a tool's schema.
319///
320/// Checks required fields, type compatibility, and enum constraints.
321/// Injects default values for missing optional fields. Returns the
322/// validated (and potentially enriched) parameters.
323pub 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    // Check required fields
351    for req_field in &required {
352        if !params_obj.contains_key(req_field) {
353            bail!("missing required parameter: {}", req_field);
354        }
355    }
356
357    // Type checking and enum validation
358    for (prop_name, prop_schema) in &properties {
359        if let Some(value) = params_obj.get(prop_name) {
360            // Type check
361            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            // Enum validation
382            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            // Inject default value if available
395            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
404/// Return a human-readable name for a JSON value's type.
405fn 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
416// ═══════════════════════════════════════════════════════════════════════
417// Execution
418// ═══════════════════════════════════════════════════════════════════════
419
420/// Execute a tool script with the given parameters.
421///
422/// Spawns a blocking thread, creates a sandboxed Lua VM with all host APIs
423/// plus the context bridge, and calls `tool.execute(params, context)`.
424///
425/// # Arguments
426///
427/// * `tool` — tool definition (script source, config, timeout).
428/// * `params` — validated JSON parameters for the tool.
429/// * `app_config` — full application config (needed for context bridge).
430///
431/// # Returns
432///
433/// The JSON value returned by `tool.execute()`.
434pub 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
450/// Dropping the async execution future signals its blocking Lua worker. The VM
451/// instruction hook observes this even though Tokio cannot abort spawn_blocking.
452struct CancelOnDrop(Arc<AtomicBool>);
453
454impl Drop for CancelOnDrop {
455    fn drop(&mut self) {
456        self.0.store(true, Ordering::Release);
457    }
458}
459
460/// Run the Lua tool synchronously on a blocking thread.
461fn 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    // Set up timeout via instruction hook
476    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    // Register all shared host APIs
495    let log_name = format!("tool:{}", tool.name);
496    register_all_host_apis(&lua, &log_name, &script_dir)?;
497
498    // Register context bridge
499    register_context_bridge(&lua, config, &tool.config)?;
500
501    // Load and execute the script
502    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    // Get tool.execute function
514    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    // Convert params to Lua table
524    let params_lua = json_value_to_lua(&lua, &params)?;
525
526    // Get the context table we already registered
527    let context: LuaTable = lua
528        .globals()
529        .get::<LuaTable>("context")
530        .map_err(|e| anyhow::anyhow!("context table missing: {}", e))?;
531
532    // Call tool.execute(params, context)
533    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    // Convert result to JSON
544    lua_value_to_json(result)
545        .map_err(|e| anyhow::anyhow!("Failed to convert tool result to JSON: {}", e))
546}
547
548// ═══════════════════════════════════════════════════════════════════════
549// Context Bridge
550// ═══════════════════════════════════════════════════════════════════════
551
552/// Register the `context` table in the Lua VM.
553///
554/// Provides `context.search`, `context.get`, `context.sources`, and
555/// `context.config`. The first three call back into Rust's async core
556/// via `tokio::runtime::Handle::block_on`.
557fn register_context_bridge(lua: &Lua, config: &Config, tool_config: &toml::Table) -> LuaResult<()> {
558    let ctx = lua.create_table()?;
559
560    // context.config — tool-specific config from ctx.toml
561    let config_lua = toml_table_to_lua(lua, tool_config)?;
562    ctx.set("config", config_lua)?;
563
564    // context.search(query, opts?) → results
565    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    // context.get(id) → document
600    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    // context.sources() → sources
614    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
627/// Convert search results to a Lua array table.
628fn 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
649/// Convert a document response to a Lua table.
650fn 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    // Chunks
670    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
682/// Convert source statuses to a Lua array table.
683fn 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
698// ═══════════════════════════════════════════════════════════════════════
699// Tool Discovery
700// ═══════════════════════════════════════════════════════════════════════
701
702/// Build the list of all tools (built-in + Lua) for the `/tools/list` endpoint.
703pub fn build_tool_list(lua_tools: &[ToolDefinition]) -> Vec<ToolInfo> {
704    let mut tools = Vec::new();
705
706    // Built-in tools
707    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    // Lua tools
753    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
765// ═══════════════════════════════════════════════════════════════════════
766// CLI: scaffold & test
767// ═══════════════════════════════════════════════════════════════════════
768
769/// Scaffold a new tool script from a template.
770///
771/// Creates `tools/<name>.lua` with a commented template showing the
772/// tool interface and available host APIs.
773pub 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
851/// Test a tool script with sample parameters.
852///
853/// Loads the script, executes `tool.execute()` with the provided parameters,
854/// and prints the result. Useful for development and debugging.
855pub 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    // Build params JSON
884    let mut params_json = serde_json::Map::new();
885    for (k, v) in &params {
886        // Try to parse as JSON value first, fall back to string
887        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
919/// List all configured tools and print their info.
920pub 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}