Skip to main content

orcs_lua/
tool_registry.rs

1//! Intent registry: structured intent definitions with unified dispatch.
2//!
3//! Provides a single source of truth for intent metadata (name, description,
4//! parameters as JSON Schema, resolver) and a unified `orcs.dispatch(name, args)`
5//! function that validates and routes to the appropriate resolver.
6//!
7//! # Design
8//!
9//! ```text
10//! IntentRegistry (dynamic, stored in Lua app_data)
11//!   ├── IntentDef { name, description, parameters (JSON Schema), resolver }
12//!   │     resolver = Internal       → dispatch_internal() (8 builtin tools)
13//!   │     resolver = Component{..}  → Component RPC via EventBus
14//!   │
15//!   └── Lua API:
16//!         orcs.dispatch(name, args)   → resolve + execute
17//!         orcs.tool_schemas()         → legacy Lua table format (backward compat)
18//!         orcs.intent_defs()          → JSON Schema format (for LLM tools param)
19//!         orcs.register_intent(def)   → dynamic addition (Component tools)
20//! ```
21
22use std::collections::HashMap;
23
24use crate::error::LuaError;
25use crate::types::serde_json_to_lua;
26use mlua::{Lua, Table};
27use orcs_types::intent::{IntentDef, IntentResolver};
28
29// ── IntentRegistry ───────────────────────────────────────────────────
30
31/// Registry of named intents. Stored in Lua app_data.
32///
33/// Initialized with 8 builtin Internal tools. Components can register
34/// additional intents at runtime via `orcs.register_intent()`.
35///
36/// Uses `Vec` for ordered iteration and `HashMap<String, usize>` index
37/// for O(1) name lookup.
38pub struct IntentRegistry {
39    defs: Vec<IntentDef>,
40    /// Maps intent name → index in `defs` for O(1) lookup.
41    index: HashMap<String, usize>,
42}
43
44impl Default for IntentRegistry {
45    fn default() -> Self {
46        Self::new()
47    }
48}
49
50impl IntentRegistry {
51    /// Create a new registry pre-populated with the 8 builtin tools.
52    pub fn new() -> Self {
53        let defs = builtin_intent_defs();
54        let index = defs
55            .iter()
56            .enumerate()
57            .map(|(i, d)| (d.name.clone(), i))
58            .collect();
59        Self { defs, index }
60    }
61
62    /// Look up an intent definition by name. O(1).
63    pub fn get(&self, name: &str) -> Option<&IntentDef> {
64        self.index.get(name).map(|&i| &self.defs[i])
65    }
66
67    /// Register a new intent definition. Returns error if name is already taken.
68    pub fn register(&mut self, def: IntentDef) -> Result<(), String> {
69        if self.index.contains_key(&def.name) {
70            return Err(format!("intent already registered: {}", def.name));
71        }
72        let idx = self.defs.len();
73        self.index.insert(def.name.clone(), idx);
74        self.defs.push(def);
75        Ok(())
76    }
77
78    /// All registered intent definitions (insertion order).
79    pub fn all(&self) -> &[IntentDef] {
80        &self.defs
81    }
82
83    /// Number of registered intents.
84    pub fn len(&self) -> usize {
85        self.defs.len()
86    }
87
88    /// Whether the registry is empty.
89    pub fn is_empty(&self) -> bool {
90        self.defs.is_empty()
91    }
92}
93
94// ── Builtin IntentDefs (8 tools) ─────────────────────────────────────
95
96/// Helper: create a JSON Schema for a tool with the given properties.
97fn json_schema(properties: &[(&str, &str, bool)]) -> serde_json::Value {
98    let mut props = serde_json::Map::new();
99    let mut required = Vec::new();
100
101    for &(name, description, is_required) in properties {
102        props.insert(
103            name.to_string(),
104            serde_json::json!({
105                "type": "string",
106                "description": description,
107            }),
108        );
109        if is_required {
110            required.push(serde_json::Value::String(name.to_string()));
111        }
112    }
113
114    serde_json::json!({
115        "type": "object",
116        "properties": props,
117        "required": required,
118    })
119}
120
121/// The 8 builtin tool definitions as IntentDefs.
122fn builtin_intent_defs() -> Vec<IntentDef> {
123    vec![
124        IntentDef {
125            name: "read".into(),
126            description: "Read file contents. Path relative to project root.".into(),
127            parameters: json_schema(&[("path", "File path to read", true)]),
128            resolver: IntentResolver::Internal,
129        },
130        IntentDef {
131            name: "write".into(),
132            description: "Write file contents (atomic). Creates parent dirs.".into(),
133            parameters: json_schema(&[
134                ("path", "File path to write", true),
135                ("content", "Content to write", true),
136            ]),
137            resolver: IntentResolver::Internal,
138        },
139        IntentDef {
140            name: "grep".into(),
141            description: "Search with regex. Path can be file or directory (recursive).".into(),
142            parameters: json_schema(&[
143                ("pattern", "Regex pattern to search for", true),
144                ("path", "File or directory to search in", true),
145            ]),
146            resolver: IntentResolver::Internal,
147        },
148        IntentDef {
149            name: "glob".into(),
150            description: "Find files by glob pattern. Dir defaults to project root.".into(),
151            parameters: json_schema(&[
152                ("pattern", "Glob pattern (e.g. '**/*.rs')", true),
153                ("dir", "Base directory (defaults to project root)", false),
154            ]),
155            resolver: IntentResolver::Internal,
156        },
157        IntentDef {
158            name: "mkdir".into(),
159            description: "Create directory (with parents).".into(),
160            parameters: json_schema(&[("path", "Directory path to create", true)]),
161            resolver: IntentResolver::Internal,
162        },
163        IntentDef {
164            name: "remove".into(),
165            description: "Remove file or directory.".into(),
166            parameters: json_schema(&[("path", "Path to remove", true)]),
167            resolver: IntentResolver::Internal,
168        },
169        IntentDef {
170            name: "mv".into(),
171            description: "Move / rename file or directory.".into(),
172            parameters: json_schema(&[
173                ("src", "Source path", true),
174                ("dst", "Destination path", true),
175            ]),
176            resolver: IntentResolver::Internal,
177        },
178        IntentDef {
179            name: "exec".into(),
180            description: "Execute shell command. cwd = project root.".into(),
181            parameters: json_schema(&[("cmd", "Shell command to execute", true)]),
182            resolver: IntentResolver::Internal,
183        },
184    ]
185}
186
187// ── Dispatch ─────────────────────────────────────────────────────────
188
189/// Dispatches a tool call by name. Routes through IntentRegistry.
190///
191/// 1. Look up intent in registry
192/// 2. Route by resolver: Internal → dispatch_internal, Component → (future)
193/// 3. Unknown name → error
194fn dispatch_tool(lua: &Lua, name: &str, args: &Table) -> mlua::Result<Table> {
195    let resolver = {
196        let registry = ensure_registry(lua)?;
197        match registry.get(name) {
198            Some(def) => def.resolver.clone(),
199            None => {
200                let result = lua.create_table()?;
201                set_error(&result, &format!("unknown intent: {name}"))?;
202                return Ok(result);
203            }
204        }
205    };
206
207    let start = std::time::Instant::now();
208    let result = match resolver {
209        IntentResolver::Internal => dispatch_internal(lua, name, args),
210        IntentResolver::Component {
211            component_fqn,
212            operation,
213        } => dispatch_component(lua, name, &component_fqn, &operation, args),
214    };
215    let duration_ms = start.elapsed().as_millis() as u64;
216    let ok = result
217        .as_ref()
218        .map(|t| t.get::<bool>("ok").unwrap_or(false))
219        .unwrap_or(false);
220    tracing::info!(
221        "intent dispatch: {name} → {ok} ({duration_ms}ms)",
222        ok = if ok { "ok" } else { "err" }
223    );
224    result
225}
226
227/// Dispatches an Internal intent to the corresponding `orcs.*` Lua function.
228///
229/// Each builtin tool has a different Lua function signature, so tool-specific
230/// argument extraction is necessary. This is an implementation detail of
231/// the Internal resolver — hidden behind the unified dispatch_tool().
232fn dispatch_internal(lua: &Lua, name: &str, args: &Table) -> mlua::Result<Table> {
233    let orcs: Table = lua.globals().get("orcs")?;
234
235    match name {
236        "read" => {
237            let path: String = get_required_arg(args, "path")?;
238            let f: mlua::Function = orcs.get("read")?;
239            f.call(path)
240        }
241        "write" => {
242            let path: String = get_required_arg(args, "path")?;
243            let content: String = get_required_arg(args, "content")?;
244            let f: mlua::Function = orcs.get("write")?;
245            f.call((path, content))
246        }
247        "grep" => {
248            let pattern: String = get_required_arg(args, "pattern")?;
249            let path: String = get_required_arg(args, "path")?;
250            let f: mlua::Function = orcs.get("grep")?;
251            f.call((pattern, path))
252        }
253        "glob" => {
254            let pattern: String = get_required_arg(args, "pattern")?;
255            let dir: Option<String> = args.get("dir").ok();
256            let f: mlua::Function = orcs.get("glob")?;
257            f.call((pattern, dir))
258        }
259        "mkdir" => {
260            let path: String = get_required_arg(args, "path")?;
261            let f: mlua::Function = orcs.get("mkdir")?;
262            f.call(path)
263        }
264        "remove" => {
265            let path: String = get_required_arg(args, "path")?;
266            let f: mlua::Function = orcs.get("remove")?;
267            f.call(path)
268        }
269        "mv" => {
270            let src: String = get_required_arg(args, "src")?;
271            let dst: String = get_required_arg(args, "dst")?;
272            let f: mlua::Function = orcs.get("mv")?;
273            f.call((src, dst))
274        }
275        "exec" => {
276            let cmd: String = get_required_arg(args, "cmd")?;
277            let f: mlua::Function = orcs.get("exec")?;
278            f.call(cmd)
279        }
280        _ => {
281            // Internal resolver for unknown name — should not happen if
282            // registry is consistent, but handle defensively.
283            let result = lua.create_table()?;
284            set_error(
285                &result,
286                &format!("internal dispatch error: no handler for '{name}'"),
287            )?;
288            Ok(result)
289        }
290    }
291}
292
293/// Dispatches a Component intent via RPC.
294///
295/// Calls `orcs.request(component_fqn, operation, args)` which is
296/// registered by emitter_fns.rs (Component) or child.rs (ChildContext).
297///
298/// The RPC returns `{ success: bool, data?, error? }`. This function
299/// normalizes the response to `{ ok: bool, data?, error?, duration_ms }`,
300/// matching the Internal dispatch contract.
301fn dispatch_component(
302    lua: &Lua,
303    intent_name: &str,
304    component_fqn: &str,
305    operation: &str,
306    args: &Table,
307) -> mlua::Result<Table> {
308    let orcs: Table = lua.globals().get("orcs")?;
309
310    let request_fn = match orcs.get::<mlua::Function>("request") {
311        Ok(f) => f,
312        Err(_) => {
313            let result = lua.create_table()?;
314            set_error(
315                &result,
316                "component dispatch unavailable: no execution context (orcs.request not registered)",
317            )?;
318            return Ok(result);
319        }
320    };
321
322    // Build RPC payload (shallow copy of args table)
323    let payload = lua.create_table()?;
324    for pair in args.pairs::<mlua::Value, mlua::Value>() {
325        let (k, v) = pair?;
326        payload.set(k, v)?;
327    }
328
329    // Execute with timing
330    let start = std::time::Instant::now();
331    let rpc_result: Table = request_fn.call((component_fqn, operation, payload))?;
332    let duration_ms = start.elapsed().as_millis() as u64;
333
334    tracing::debug!(
335        "component dispatch: {intent_name} → {component_fqn}::{operation} ({duration_ms}ms)"
336    );
337
338    // Normalize { success, data, error } → { ok, data, error, duration_ms }
339    let result = lua.create_table()?;
340    let success: bool = rpc_result.get("success").unwrap_or(false);
341    result.set("ok", success)?;
342    result.set("duration_ms", duration_ms)?;
343
344    if success {
345        // Forward data if present
346        if let Ok(data) = rpc_result.get::<mlua::Value>("data") {
347            result.set("data", data)?;
348        }
349    } else {
350        // Forward error message
351        let error_msg: String = rpc_result
352            .get("error")
353            .unwrap_or_else(|_| format!("component RPC failed: {component_fqn}::{operation}"));
354        result.set("error", error_msg)?;
355    }
356
357    Ok(result)
358}
359
360// ── Registry Helpers ─────────────────────────────────────────────────
361
362/// Ensure IntentRegistry exists in app_data. Returns a reference.
363fn ensure_registry(lua: &Lua) -> mlua::Result<mlua::AppDataRef<'_, IntentRegistry>> {
364    if lua.app_data_ref::<IntentRegistry>().is_none() {
365        lua.set_app_data(IntentRegistry::new());
366    }
367    lua.app_data_ref::<IntentRegistry>().ok_or_else(|| {
368        mlua::Error::RuntimeError("IntentRegistry not available after initialization".into())
369    })
370}
371
372/// Generates formatted tool descriptions from the registry.
373pub fn generate_descriptions(lua: &Lua) -> String {
374    let registry = match ensure_registry(lua) {
375        Ok(r) => r,
376        Err(_) => return "IntentRegistry not available.\n".to_string(),
377    };
378
379    let mut out = String::from("Available tools (use via orcs.dispatch):\n\n");
380
381    for def in registry.all() {
382        // Extract argument names from JSON Schema
383        let args_fmt = extract_arg_names(&def.parameters);
384        out.push_str(&format!(
385            "{}({}) - {}\n",
386            def.name, args_fmt, def.description
387        ));
388    }
389
390    out.push_str("\norcs.pwd - Project root path (string).\n");
391    out
392}
393
394/// Extract argument names from a JSON Schema `properties` + `required` for display.
395fn extract_arg_names(schema: &serde_json::Value) -> String {
396    let properties = match schema.get("properties").and_then(|p| p.as_object()) {
397        Some(p) => p,
398        None => return String::new(),
399    };
400
401    let required: Vec<&str> = schema
402        .get("required")
403        .and_then(|r| r.as_array())
404        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
405        .unwrap_or_default();
406
407    properties
408        .keys()
409        .map(|name| {
410            if required.contains(&name.as_str()) {
411                name.clone()
412            } else {
413                format!("{name}?")
414            }
415        })
416        .collect::<Vec<_>>()
417        .join(", ")
418}
419
420// ── Arg extraction helpers ───────────────────────────────────────────
421
422/// Extracts a required string argument from the args table.
423fn get_required_arg(args: &Table, name: &str) -> mlua::Result<String> {
424    args.get::<String>(name)
425        .map_err(|_| mlua::Error::RuntimeError(format!("missing required argument: {name}")))
426}
427
428/// Sets error fields on a result table.
429fn set_error(result: &Table, msg: &str) -> mlua::Result<()> {
430    result.set("ok", false)?;
431    result.set("error", msg.to_string())?;
432    Ok(())
433}
434
435// ── Lua API Registration ─────────────────────────────────────────────
436
437/// Registers intent-based Lua APIs in the runtime.
438///
439/// - `orcs.dispatch(name, args)` — unified intent dispatcher
440/// - `orcs.tool_schemas()` — legacy Lua table format (backward compat)
441/// - `orcs.intent_defs()` — JSON Schema format (for LLM tools param)
442/// - `orcs.register_intent(def)` — dynamic intent registration
443/// - `orcs.tool_descriptions()` — formatted text descriptions
444pub fn register_dispatch_functions(lua: &Lua) -> Result<(), LuaError> {
445    // Ensure registry is initialized
446    if lua.app_data_ref::<IntentRegistry>().is_none() {
447        lua.set_app_data(IntentRegistry::new());
448    }
449
450    let orcs_table: Table = lua.globals().get("orcs")?;
451
452    // orcs.dispatch(name, args) -> result table
453    let dispatch_fn =
454        lua.create_function(|lua, (name, args): (String, Table)| dispatch_tool(lua, &name, &args))?;
455    orcs_table.set("dispatch", dispatch_fn)?;
456
457    // orcs.tool_schemas() -> legacy Lua table format (backward compat)
458    let schemas_fn = lua.create_function(|lua, ()| {
459        let registry = ensure_registry(lua)?;
460        let result = lua.create_table()?;
461
462        for (i, def) in registry.all().iter().enumerate() {
463            let entry = lua.create_table()?;
464            entry.set("name", def.name.as_str())?;
465            entry.set("description", def.description.as_str())?;
466
467            // Convert JSON Schema properties to legacy args format
468            let args_table = lua.create_table()?;
469            if let Some(properties) = def.parameters.get("properties").and_then(|p| p.as_object()) {
470                let required: Vec<&str> = def
471                    .parameters
472                    .get("required")
473                    .and_then(|r| r.as_array())
474                    .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
475                    .unwrap_or_default();
476
477                for (j, (prop_name, prop_schema)) in properties.iter().enumerate() {
478                    let arg_entry = lua.create_table()?;
479                    arg_entry.set("name", prop_name.as_str())?;
480
481                    let is_required = required.contains(&prop_name.as_str());
482                    let type_str = if is_required { "string" } else { "string?" };
483                    arg_entry.set("type", type_str)?;
484                    arg_entry.set("required", is_required)?;
485
486                    let description = prop_schema
487                        .get("description")
488                        .and_then(|d| d.as_str())
489                        .unwrap_or("");
490                    arg_entry.set("description", description)?;
491
492                    args_table.set(j + 1, arg_entry)?;
493                }
494            }
495            entry.set("args", args_table)?;
496            result.set(i + 1, entry)?;
497        }
498
499        Ok(result)
500    })?;
501    orcs_table.set("tool_schemas", schemas_fn)?;
502
503    // orcs.intent_defs() -> JSON Schema format for LLM tools parameter
504    let intent_defs_fn = lua.create_function(|lua, ()| {
505        let registry = ensure_registry(lua)?;
506        let result = lua.create_table()?;
507
508        for (i, def) in registry.all().iter().enumerate() {
509            let entry = lua.create_table()?;
510            entry.set("name", def.name.as_str())?;
511            entry.set("description", def.description.as_str())?;
512
513            // parameters as native Lua table (JSON Schema → Lua via serde_json_to_lua)
514            let params_value = serde_json_to_lua(&def.parameters, lua)?;
515            entry.set("parameters", params_value)?;
516
517            result.set(i + 1, entry)?;
518        }
519
520        Ok(result)
521    })?;
522    orcs_table.set("intent_defs", intent_defs_fn)?;
523
524    // orcs.register_intent(def) -> register a new intent definition
525    let register_fn = lua.create_function(|lua, def_table: Table| {
526        let name: String = def_table
527            .get("name")
528            .map_err(|_| mlua::Error::RuntimeError("register_intent: 'name' is required".into()))?;
529        let description: String = def_table.get("description").map_err(|_| {
530            mlua::Error::RuntimeError("register_intent: 'description' is required".into())
531        })?;
532
533        // Component resolver fields
534        let component_fqn: String = def_table.get("component").map_err(|_| {
535            mlua::Error::RuntimeError("register_intent: 'component' is required".into())
536        })?;
537        let operation: String = def_table
538            .get("operation")
539            .unwrap_or_else(|_| "execute".to_string());
540
541        // Parameters: accept a table or default to empty schema
542        let parameters = match def_table.get::<Table>("params") {
543            Ok(params_table) => {
544                // Convert Lua table to JSON Schema
545                let mut properties = serde_json::Map::new();
546                let mut required = Vec::new();
547
548                for pair in params_table.pairs::<String, Table>() {
549                    let (param_name, param_def) = pair?;
550                    let type_str: String = param_def
551                        .get("type")
552                        .unwrap_or_else(|_| "string".to_string());
553                    let desc: String = param_def
554                        .get("description")
555                        .unwrap_or_else(|_| String::new());
556                    let is_required: bool = param_def.get("required").unwrap_or(false);
557
558                    properties.insert(
559                        param_name.clone(),
560                        serde_json::json!({
561                            "type": type_str,
562                            "description": desc,
563                        }),
564                    );
565                    if is_required {
566                        required.push(serde_json::Value::String(param_name));
567                    }
568                }
569
570                serde_json::json!({
571                    "type": "object",
572                    "properties": properties,
573                    "required": required,
574                })
575            }
576            Err(_) => serde_json::json!({"type": "object", "properties": {}}),
577        };
578
579        let intent_def = IntentDef {
580            name: name.clone(),
581            description,
582            parameters,
583            resolver: IntentResolver::Component {
584                component_fqn,
585                operation,
586            },
587        };
588
589        // Mutate registry
590        if let Some(mut registry) = lua.remove_app_data::<IntentRegistry>() {
591            let result = registry.register(intent_def);
592            lua.set_app_data(registry);
593
594            let result_table = lua.create_table()?;
595            match result {
596                Ok(()) => {
597                    result_table.set("ok", true)?;
598                }
599                Err(e) => {
600                    result_table.set("ok", false)?;
601                    result_table.set("error", e)?;
602                }
603            }
604            Ok(result_table)
605        } else {
606            Err(mlua::Error::RuntimeError(
607                "IntentRegistry not initialized".into(),
608            ))
609        }
610    })?;
611    orcs_table.set("register_intent", register_fn)?;
612
613    // orcs.tool_descriptions() -> formatted text
614    let desc = generate_descriptions(lua);
615    let tool_desc_fn = lua.create_function(move |_, ()| Ok(desc.clone()))?;
616    orcs_table.set("tool_descriptions", tool_desc_fn)?;
617
618    Ok(())
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use crate::orcs_helpers::register_base_orcs_functions;
625    use orcs_runtime::sandbox::{ProjectSandbox, SandboxPolicy};
626    use std::fs;
627    use std::path::PathBuf;
628    use std::sync::Arc;
629
630    fn test_sandbox() -> (PathBuf, Arc<dyn SandboxPolicy>) {
631        let dir = tempdir();
632        let sandbox = ProjectSandbox::new(&dir).expect("test sandbox");
633        (dir, Arc::new(sandbox))
634    }
635
636    fn tempdir() -> PathBuf {
637        let dir = std::env::temp_dir().join(format!(
638            "orcs-registry-test-{}-{}",
639            std::process::id(),
640            std::time::SystemTime::now()
641                .duration_since(std::time::UNIX_EPOCH)
642                .expect("system time should be after epoch")
643                .as_nanos()
644        ));
645        std::fs::create_dir_all(&dir).expect("should create temp dir");
646        dir.canonicalize().expect("should canonicalize temp dir")
647    }
648
649    fn setup_lua(sandbox: Arc<dyn SandboxPolicy>) -> Lua {
650        let lua = Lua::new();
651        register_base_orcs_functions(&lua, sandbox).expect("should register base functions");
652        lua
653    }
654
655    // --- IntentRegistry unit tests ---
656
657    #[test]
658    fn registry_new_has_8_builtins() {
659        let registry = IntentRegistry::new();
660        assert_eq!(registry.len(), 8, "should have 8 builtin intents");
661    }
662
663    #[test]
664    fn registry_get_existing() {
665        let registry = IntentRegistry::new();
666        let def = registry.get("read").expect("'read' should exist");
667        assert_eq!(def.name, "read");
668        assert_eq!(def.resolver, IntentResolver::Internal);
669    }
670
671    #[test]
672    fn registry_get_nonexistent() {
673        let registry = IntentRegistry::new();
674        assert!(registry.get("nonexistent").is_none());
675    }
676
677    #[test]
678    fn registry_register_new_intent() {
679        let mut registry = IntentRegistry::new();
680        let def = IntentDef {
681            name: "custom_tool".into(),
682            description: "A custom tool".into(),
683            parameters: serde_json::json!({"type": "object", "properties": {}}),
684            resolver: IntentResolver::Component {
685                component_fqn: "lua::my_comp".into(),
686                operation: "execute".into(),
687            },
688        };
689        registry
690            .register(def)
691            .expect("should register successfully");
692        assert_eq!(registry.len(), 9);
693        assert!(registry.get("custom_tool").is_some());
694    }
695
696    #[test]
697    fn registry_register_duplicate_fails() {
698        let mut registry = IntentRegistry::new();
699        let def = IntentDef {
700            name: "read".into(),
701            description: "duplicate".into(),
702            parameters: serde_json::json!({}),
703            resolver: IntentResolver::Internal,
704        };
705        let err = registry.register(def).expect_err("should reject duplicate");
706        assert!(
707            err.contains("already registered"),
708            "error should mention duplicate, got: {err}"
709        );
710    }
711
712    #[test]
713    fn registry_all_intent_defs_have_json_schema() {
714        let registry = IntentRegistry::new();
715        for def in registry.all() {
716            assert_eq!(
717                def.parameters.get("type").and_then(|v| v.as_str()),
718                Some("object"),
719                "intent '{}' should have JSON Schema with type=object",
720                def.name
721            );
722            assert!(
723                def.parameters.get("properties").is_some(),
724                "intent '{}' should have properties",
725                def.name
726            );
727        }
728    }
729
730    #[test]
731    fn builtin_intent_names_match_expected() {
732        let registry = IntentRegistry::new();
733        let names: Vec<&str> = registry.all().iter().map(|d| d.name.as_str()).collect();
734        assert_eq!(
735            names,
736            vec!["read", "write", "grep", "glob", "mkdir", "remove", "mv", "exec"]
737        );
738    }
739
740    // --- dispatch tests (unchanged behavior) ---
741
742    #[test]
743    fn dispatch_read() {
744        let (root, sandbox) = test_sandbox();
745        fs::write(root.join("test.txt"), "hello dispatch").expect("should write test file");
746
747        let lua = setup_lua(sandbox);
748        let result: Table = lua
749            .load(format!(
750                r#"return orcs.dispatch("read", {{path="{}"}})"#,
751                root.join("test.txt").display()
752            ))
753            .eval()
754            .expect("dispatch read should succeed");
755
756        assert!(result.get::<bool>("ok").expect("should have ok field"));
757        assert_eq!(
758            result
759                .get::<String>("content")
760                .expect("should have content"),
761            "hello dispatch"
762        );
763    }
764
765    #[test]
766    fn dispatch_write_and_read() {
767        let (root, sandbox) = test_sandbox();
768        let path = root.join("written.txt");
769
770        let lua = setup_lua(sandbox);
771        let code = format!(
772            r#"
773            local w = orcs.dispatch("write", {{path="{p}", content="via dispatch"}})
774            local r = orcs.dispatch("read", {{path="{p}"}})
775            return r
776            "#,
777            p = path.display()
778        );
779        let result: Table = lua
780            .load(&code)
781            .eval()
782            .expect("dispatch write+read should succeed");
783        assert!(result.get::<bool>("ok").expect("should have ok field"));
784        assert_eq!(
785            result
786                .get::<String>("content")
787                .expect("should have content"),
788            "via dispatch"
789        );
790    }
791
792    #[test]
793    fn dispatch_grep() {
794        let (root, sandbox) = test_sandbox();
795        fs::write(root.join("search.txt"), "line one\nline two\nthird")
796            .expect("should write search file");
797
798        let lua = setup_lua(sandbox);
799        let result: Table = lua
800            .load(format!(
801                r#"return orcs.dispatch("grep", {{pattern="line", path="{}"}})"#,
802                root.join("search.txt").display()
803            ))
804            .eval()
805            .expect("dispatch grep should succeed");
806
807        assert!(result.get::<bool>("ok").expect("should have ok field"));
808        assert_eq!(result.get::<usize>("count").expect("should have count"), 2);
809    }
810
811    #[test]
812    fn dispatch_glob() {
813        let (root, sandbox) = test_sandbox();
814        fs::write(root.join("a.rs"), "").expect("write a.rs");
815        fs::write(root.join("b.rs"), "").expect("write b.rs");
816        fs::write(root.join("c.txt"), "").expect("write c.txt");
817
818        let lua = setup_lua(sandbox);
819        let result: Table = lua
820            .load(format!(
821                r#"return orcs.dispatch("glob", {{pattern="*.rs", dir="{}"}})"#,
822                root.display()
823            ))
824            .eval()
825            .expect("dispatch glob should succeed");
826
827        assert!(result.get::<bool>("ok").expect("should have ok field"));
828        assert_eq!(result.get::<usize>("count").expect("should have count"), 2);
829    }
830
831    #[test]
832    fn dispatch_mkdir_remove() {
833        let (root, sandbox) = test_sandbox();
834        let dir_path = root.join("sub/deep");
835
836        let lua = setup_lua(sandbox);
837        let code = format!(
838            r#"
839            local m = orcs.dispatch("mkdir", {{path="{p}"}})
840            local r = orcs.dispatch("remove", {{path="{p}"}})
841            return {{mkdir=m, remove=r}}
842            "#,
843            p = dir_path.display()
844        );
845        let result: Table = lua
846            .load(&code)
847            .eval()
848            .expect("dispatch mkdir+remove should succeed");
849        let mkdir: Table = result.get("mkdir").expect("should have mkdir");
850        let remove: Table = result.get("remove").expect("should have remove");
851        assert!(mkdir.get::<bool>("ok").expect("mkdir ok"));
852        assert!(remove.get::<bool>("ok").expect("remove ok"));
853    }
854
855    #[test]
856    fn dispatch_mv() {
857        let (root, sandbox) = test_sandbox();
858        let src = root.join("src.txt");
859        let dst = root.join("dst.txt");
860        fs::write(&src, "move me").expect("write src");
861
862        let lua = setup_lua(sandbox);
863        let result: Table = lua
864            .load(format!(
865                r#"return orcs.dispatch("mv", {{src="{}", dst="{}"}})"#,
866                src.display(),
867                dst.display()
868            ))
869            .eval()
870            .expect("dispatch mv should succeed");
871
872        assert!(result.get::<bool>("ok").expect("should have ok field"));
873        assert!(dst.exists());
874        assert!(!src.exists());
875    }
876
877    #[test]
878    fn dispatch_unknown_tool() {
879        let (_, sandbox) = test_sandbox();
880        let lua = setup_lua(sandbox);
881
882        let result: Table = lua
883            .load(r#"return orcs.dispatch("nonexistent", {arg="val"})"#)
884            .eval()
885            .expect("dispatch unknown should return error table");
886
887        assert!(!result.get::<bool>("ok").expect("should have ok field"));
888        assert!(result
889            .get::<String>("error")
890            .expect("should have error")
891            .contains("unknown intent"));
892    }
893
894    #[test]
895    fn dispatch_missing_required_arg() {
896        let (_, sandbox) = test_sandbox();
897        let lua = setup_lua(sandbox);
898
899        let result = lua
900            .load(r#"return orcs.dispatch("read", {})"#)
901            .eval::<Table>();
902
903        assert!(result.is_err());
904        let err = result.expect_err("should error on missing arg").to_string();
905        assert!(err.contains("missing required argument"), "got: {err}");
906    }
907
908    // --- tool_schemas tests (backward compat) ---
909
910    #[test]
911    fn tool_schemas_returns_all() {
912        let (_, sandbox) = test_sandbox();
913        let lua = setup_lua(sandbox);
914
915        let schemas: Table = lua
916            .load("return orcs.tool_schemas()")
917            .eval()
918            .expect("tool_schemas should return table");
919
920        let count = schemas.len().expect("should have length") as usize;
921        assert_eq!(count, 8, "should return 8 builtin tools");
922
923        // Verify first schema structure (backward compat format)
924        let first: Table = schemas.get(1).expect("should have first entry");
925        assert_eq!(
926            first.get::<String>("name").expect("should have name"),
927            "read"
928        );
929        assert!(!first
930            .get::<String>("description")
931            .expect("should have description")
932            .is_empty());
933
934        let args: Table = first.get("args").expect("should have args");
935        let first_arg: Table = args.get(1).expect("should have first arg");
936        assert_eq!(first_arg.get::<String>("name").expect("arg name"), "path");
937        assert_eq!(first_arg.get::<String>("type").expect("arg type"), "string");
938        assert!(first_arg.get::<bool>("required").expect("arg required"));
939    }
940
941    // --- generate_descriptions tests ---
942
943    #[test]
944    fn descriptions_include_all_tools() {
945        let lua = Lua::new();
946        lua.set_app_data(IntentRegistry::new());
947        let desc = generate_descriptions(&lua);
948        let expected_tools = [
949            "read", "write", "grep", "glob", "mkdir", "remove", "mv", "exec",
950        ];
951        for tool in expected_tools {
952            assert!(desc.contains(tool), "missing tool in descriptions: {tool}");
953        }
954    }
955
956    // --- exec dispatch delegates to registered function ---
957
958    #[test]
959    fn dispatch_exec_uses_registered_exec() {
960        let (_, sandbox) = test_sandbox();
961        let lua = setup_lua(sandbox);
962
963        // Default exec is deny-stub
964        let result: Table = lua
965            .load(r#"return orcs.dispatch("exec", {cmd="echo hi"})"#)
966            .eval()
967            .expect("dispatch exec should return table");
968
969        // Should return the deny-stub result (not error, just ok=false)
970        assert!(!result.get::<bool>("ok").expect("should have ok field"));
971    }
972
973    // --- intent_defs tests ---
974
975    #[test]
976    fn intent_defs_returns_all() {
977        let (_, sandbox) = test_sandbox();
978        let lua = setup_lua(sandbox);
979
980        let defs: Table = lua
981            .load("return orcs.intent_defs()")
982            .eval()
983            .expect("intent_defs should return table");
984
985        let count = defs.len().expect("should have length") as usize;
986        assert_eq!(count, 8, "should return 8 builtin intents");
987
988        let first: Table = defs.get(1).expect("should have first entry");
989        assert_eq!(
990            first.get::<String>("name").expect("should have name"),
991            "read"
992        );
993        assert!(!first
994            .get::<String>("description")
995            .expect("should have description")
996            .is_empty());
997
998        // parameters should be present (as string or table)
999        assert!(
1000            first.get::<mlua::Value>("parameters").is_ok(),
1001            "should have parameters"
1002        );
1003    }
1004
1005    // --- register_intent tests ---
1006
1007    #[test]
1008    fn register_intent_adds_to_registry() {
1009        let (_, sandbox) = test_sandbox();
1010        let lua = setup_lua(sandbox);
1011
1012        let result: Table = lua
1013            .load(
1014                r#"
1015                return orcs.register_intent({
1016                    name = "custom_action",
1017                    description = "A custom action",
1018                    component = "lua::my_component",
1019                    operation = "do_stuff",
1020                    params = {
1021                        input = { type = "string", description = "Input data", required = true },
1022                    },
1023                })
1024                "#,
1025            )
1026            .eval()
1027            .expect("register_intent should return table");
1028
1029        assert!(
1030            result.get::<bool>("ok").expect("should have ok field"),
1031            "registration should succeed"
1032        );
1033
1034        // Verify it appears in tool_schemas
1035        let schemas: Table = lua
1036            .load("return orcs.tool_schemas()")
1037            .eval()
1038            .expect("tool_schemas after register");
1039        let count = schemas.len().expect("should have length") as usize;
1040        assert_eq!(count, 9, "should now have 9 intents (8 builtin + 1 custom)");
1041    }
1042
1043    #[test]
1044    fn register_intent_duplicate_fails() {
1045        let (_, sandbox) = test_sandbox();
1046        let lua = setup_lua(sandbox);
1047
1048        let result: Table = lua
1049            .load(
1050                r#"
1051                return orcs.register_intent({
1052                    name = "read",
1053                    description = "duplicate",
1054                    component = "lua::x",
1055                })
1056                "#,
1057            )
1058            .eval()
1059            .expect("register_intent should return table");
1060
1061        assert!(
1062            !result.get::<bool>("ok").expect("should have ok field"),
1063            "duplicate registration should fail"
1064        );
1065        assert!(result
1066            .get::<String>("error")
1067            .expect("should have error")
1068            .contains("already registered"));
1069    }
1070
1071    // --- Component dispatch tests ---
1072
1073    #[test]
1074    fn dispatch_component_no_request_fn_returns_error() {
1075        let (_, sandbox) = test_sandbox();
1076        let lua = setup_lua(sandbox);
1077
1078        // Register a Component intent without providing orcs.request
1079        lua.load(
1080            r#"
1081            orcs.register_intent({
1082                name = "comp_action",
1083                description = "component action",
1084                component = "lua::test_comp",
1085                operation = "do_stuff",
1086            })
1087            "#,
1088        )
1089        .exec()
1090        .expect("register should succeed");
1091
1092        let result: Table = lua
1093            .load(r#"return orcs.dispatch("comp_action", {input="hello"})"#)
1094            .eval()
1095            .expect("should return error table");
1096
1097        assert!(
1098            !result.get::<bool>("ok").expect("should have ok"),
1099            "should fail without orcs.request"
1100        );
1101        let error: String = result.get("error").expect("should have error");
1102        assert!(
1103            error.contains("no execution context"),
1104            "error should mention missing context, got: {error}"
1105        );
1106    }
1107
1108    #[test]
1109    fn dispatch_component_success_normalized() {
1110        let (_, sandbox) = test_sandbox();
1111        let lua = setup_lua(sandbox);
1112
1113        // Register a Component intent
1114        lua.load(
1115            r#"
1116            orcs.register_intent({
1117                name = "mock_comp",
1118                description = "mock component",
1119                component = "lua::mock",
1120                operation = "echo",
1121            })
1122            "#,
1123        )
1124        .exec()
1125        .expect("register should succeed");
1126
1127        // Mock orcs.request to return { success=true, data={echo="hi"} }
1128        lua.load(
1129            r#"
1130            orcs.request = function(target, operation, payload)
1131                return { success = true, data = { echo = payload.input, target = target, op = operation } }
1132            end
1133            "#,
1134        )
1135        .exec()
1136        .expect("mock should succeed");
1137
1138        let result: Table = lua
1139            .load(r#"return orcs.dispatch("mock_comp", {input="hello"})"#)
1140            .eval()
1141            .expect("dispatch should return table");
1142
1143        // Normalized response: ok (not success)
1144        assert!(
1145            result.get::<bool>("ok").expect("should have ok"),
1146            "should succeed"
1147        );
1148
1149        // duration_ms should be present
1150        let duration: u64 = result.get("duration_ms").expect("should have duration_ms");
1151        assert!(
1152            duration < 1000,
1153            "local mock should be fast, got: {duration}ms"
1154        );
1155
1156        // data should be forwarded
1157        let data: Table = result.get("data").expect("should have data");
1158        assert_eq!(
1159            data.get::<String>("echo").expect("should have echo"),
1160            "hello"
1161        );
1162        assert_eq!(
1163            data.get::<String>("target").expect("should have target"),
1164            "lua::mock"
1165        );
1166        assert_eq!(data.get::<String>("op").expect("should have op"), "echo");
1167    }
1168
1169    #[test]
1170    fn dispatch_component_failure_normalized() {
1171        let (_, sandbox) = test_sandbox();
1172        let lua = setup_lua(sandbox);
1173
1174        // Register + mock failing request
1175        lua.load(
1176            r#"
1177            orcs.register_intent({
1178                name = "fail_comp",
1179                description = "failing component",
1180                component = "lua::fail",
1181                operation = "explode",
1182            })
1183            orcs.request = function(target, operation, payload)
1184                return { success = false, error = "component exploded" }
1185            end
1186            "#,
1187        )
1188        .exec()
1189        .expect("setup should succeed");
1190
1191        let result: Table = lua
1192            .load(r#"return orcs.dispatch("fail_comp", {})"#)
1193            .eval()
1194            .expect("dispatch should return table");
1195
1196        assert!(
1197            !result.get::<bool>("ok").expect("should have ok"),
1198            "should report failure"
1199        );
1200        let error: String = result.get("error").expect("should have error");
1201        assert_eq!(error, "component exploded");
1202
1203        // duration_ms still present
1204        assert!(result.get::<u64>("duration_ms").is_ok());
1205    }
1206
1207    #[test]
1208    fn dispatch_component_forwards_all_args() {
1209        let (_, sandbox) = test_sandbox();
1210        let lua = setup_lua(sandbox);
1211
1212        lua.load(
1213            r#"
1214            orcs.register_intent({
1215                name = "args_comp",
1216                description = "args test",
1217                component = "lua::args_test",
1218                operation = "check_args",
1219            })
1220            -- Mock that captures and returns the payload
1221            orcs.request = function(target, operation, payload)
1222                return { success = true, data = payload }
1223            end
1224            "#,
1225        )
1226        .exec()
1227        .expect("setup should succeed");
1228
1229        let result: Table = lua
1230            .load(r#"return orcs.dispatch("args_comp", {a="1", b="2", c="3"})"#)
1231            .eval()
1232            .expect("dispatch should return table");
1233
1234        assert!(result.get::<bool>("ok").expect("should have ok"));
1235        let data: Table = result.get("data").expect("should have data");
1236        assert_eq!(data.get::<String>("a").expect("arg a"), "1");
1237        assert_eq!(data.get::<String>("b").expect("arg b"), "2");
1238        assert_eq!(data.get::<String>("c").expect("arg c"), "3");
1239    }
1240
1241    // --- register_intent validation tests ---
1242
1243    #[test]
1244    fn register_intent_missing_name_errors() {
1245        let (_, sandbox) = test_sandbox();
1246        let lua = setup_lua(sandbox);
1247
1248        let result = lua
1249            .load(
1250                r#"
1251                return orcs.register_intent({
1252                    description = "no name",
1253                    component = "lua::x",
1254                })
1255                "#,
1256            )
1257            .eval::<Table>();
1258
1259        assert!(result.is_err(), "missing 'name' should cause a Lua error");
1260        let err = result.expect_err("should error").to_string();
1261        assert!(
1262            err.contains("name"),
1263            "error should mention 'name', got: {err}"
1264        );
1265    }
1266
1267    #[test]
1268    fn register_intent_missing_description_errors() {
1269        let (_, sandbox) = test_sandbox();
1270        let lua = setup_lua(sandbox);
1271
1272        let result = lua
1273            .load(
1274                r#"
1275                return orcs.register_intent({
1276                    name = "no_desc",
1277                    component = "lua::x",
1278                })
1279                "#,
1280            )
1281            .eval::<Table>();
1282
1283        assert!(
1284            result.is_err(),
1285            "missing 'description' should cause a Lua error"
1286        );
1287        let err = result.expect_err("should error").to_string();
1288        assert!(
1289            err.contains("description"),
1290            "error should mention 'description', got: {err}"
1291        );
1292    }
1293
1294    #[test]
1295    fn register_intent_missing_component_errors() {
1296        let (_, sandbox) = test_sandbox();
1297        let lua = setup_lua(sandbox);
1298
1299        let result = lua
1300            .load(
1301                r#"
1302                return orcs.register_intent({
1303                    name = "no_comp",
1304                    description = "missing component",
1305                })
1306                "#,
1307            )
1308            .eval::<Table>();
1309
1310        assert!(
1311            result.is_err(),
1312            "missing 'component' should cause a Lua error"
1313        );
1314        let err = result.expect_err("should error").to_string();
1315        assert!(
1316            err.contains("component"),
1317            "error should mention 'component', got: {err}"
1318        );
1319    }
1320
1321    #[test]
1322    fn register_intent_defaults_operation_to_execute() {
1323        let (_, sandbox) = test_sandbox();
1324        let lua = setup_lua(sandbox);
1325
1326        // Register without specifying operation
1327        let result: Table = lua
1328            .load(
1329                r#"
1330                return orcs.register_intent({
1331                    name = "default_op",
1332                    description = "test default operation",
1333                    component = "lua::test_comp",
1334                })
1335                "#,
1336            )
1337            .eval()
1338            .expect("register_intent should return table");
1339
1340        assert!(
1341            result.get::<bool>("ok").expect("should have ok"),
1342            "registration should succeed"
1343        );
1344
1345        // Dispatch it to verify operation defaults to "execute"
1346        // Mock orcs.request to capture the operation argument
1347        lua.load(
1348            r#"
1349            orcs.request = function(target, operation, payload)
1350                return { success = true, data = { captured_op = operation } }
1351            end
1352            "#,
1353        )
1354        .exec()
1355        .expect("mock should succeed");
1356
1357        let dispatch_result: Table = lua
1358            .load(r#"return orcs.dispatch("default_op", {})"#)
1359            .eval()
1360            .expect("dispatch should return table");
1361
1362        assert!(dispatch_result.get::<bool>("ok").expect("should have ok"));
1363        let data: Table = dispatch_result.get("data").expect("should have data");
1364        assert_eq!(
1365            data.get::<String>("captured_op").expect("captured_op"),
1366            "execute",
1367            "operation should default to 'execute'"
1368        );
1369    }
1370}