Skip to main content

orcs_component/
tool.rs

1//! RustTool trait for self-describing, sandboxed tool plugins.
2//!
3//! A `RustTool` is a unit of functionality that:
4//! - Declares its identity (name, description, parameter schema)
5//! - Declares its permission requirement (capability, read-only flag)
6//! - Executes sandboxed logic given validated arguments
7//!
8//! # Dispatch Flow
9//!
10//! ```text
11//! orcs.dispatch("read", {path: "src/main.rs"})
12//!   → IntentRegistry lookup → RustTool found
13//!   → capability check (READ) ✓
14//!   → approval check (read_only=true → exempt) ✓
15//!   → tool.execute(args, &ctx)
16//!   → Result<Value, ToolError>
17//! ```
18//!
19//! # Adding a New Tool
20//!
21//! ```rust
22//! use orcs_component::{RustTool, ToolContext, ToolError, Capability};
23//! use serde_json::{json, Value};
24//!
25//! struct CountLinesTool;
26//!
27//! impl RustTool for CountLinesTool {
28//!     fn name(&self) -> &str { "count_lines" }
29//!
30//!     fn description(&self) -> &str {
31//!         "Count the number of lines in a file"
32//!     }
33//!
34//!     fn parameters_schema(&self) -> Value {
35//!         json!({
36//!             "type": "object",
37//!             "properties": {
38//!                 "path": { "type": "string", "description": "File path" }
39//!             },
40//!             "required": ["path"]
41//!         })
42//!     }
43//!
44//!     fn required_capability(&self) -> Capability { Capability::READ }
45//!     fn is_read_only(&self) -> bool { true }
46//!
47//!     fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<Value, ToolError> {
48//!         let path = args["path"].as_str()
49//!             .ok_or_else(|| ToolError::new("missing 'path' argument"))?;
50//!         let canonical = ctx.sandbox()
51//!             .validate_read(path)
52//!             .map_err(|e| ToolError::new(e.to_string()))?;
53//!         let content = std::fs::read_to_string(&canonical)
54//!             .map_err(|e| ToolError::new(e.to_string()))?;
55//!         Ok(json!({ "lines": content.lines().count() }))
56//!     }
57//! }
58//! ```
59
60use crate::capability::Capability;
61use crate::context::ChildContext;
62use orcs_auth::SandboxPolicy;
63use orcs_types::intent::{IntentDef, IntentResolver};
64use thiserror::Error;
65
66// ─── ToolError ──────────────────────────────────────────────────────
67
68/// Error from tool execution.
69///
70/// Kept intentionally simple. The dispatcher translates this
71/// into Lua error tables or `IntentResult.error`.
72#[derive(Debug, Clone, Error)]
73#[error("{message}")]
74pub struct ToolError {
75    message: String,
76}
77
78impl ToolError {
79    /// Create a new tool error.
80    pub fn new(msg: impl Into<String>) -> Self {
81        Self {
82            message: msg.into(),
83        }
84    }
85
86    /// Error message.
87    pub fn message(&self) -> &str {
88        &self.message
89    }
90}
91
92impl From<String> for ToolError {
93    fn from(s: String) -> Self {
94        Self::new(s)
95    }
96}
97
98impl From<std::io::Error> for ToolError {
99    fn from(e: std::io::Error) -> Self {
100        Self::new(e.to_string())
101    }
102}
103
104impl From<orcs_auth::SandboxError> for ToolError {
105    fn from(e: orcs_auth::SandboxError) -> Self {
106        Self::new(e.to_string())
107    }
108}
109
110// ─── ToolContext ────────────────────────────────────────────────────
111
112/// Execution context provided to [`RustTool::execute`].
113///
114/// Created by the dispatcher before calling a tool. Provides:
115/// - **Sandbox**: path validation for file I/O
116/// - **ChildContext** (optional): runtime interaction (command
117///   permissions, output emission, etc.)
118///
119/// # Lifetime
120///
121/// Borrows from the dispatcher's lock scope. Tools must not
122/// store references beyond `execute()`.
123pub struct ToolContext<'a> {
124    sandbox: &'a dyn SandboxPolicy,
125    child_ctx: Option<&'a dyn ChildContext>,
126}
127
128impl<'a> ToolContext<'a> {
129    /// Create a context with only sandbox (for tests or standalone use).
130    pub fn new(sandbox: &'a dyn SandboxPolicy) -> Self {
131        Self {
132            sandbox,
133            child_ctx: None,
134        }
135    }
136
137    /// Attach a child context (builder pattern).
138    #[must_use]
139    pub fn with_child_ctx(mut self, ctx: &'a dyn ChildContext) -> Self {
140        self.child_ctx = Some(ctx);
141        self
142    }
143
144    /// Sandbox policy for file path validation.
145    pub fn sandbox(&self) -> &dyn SandboxPolicy {
146        self.sandbox
147    }
148
149    /// Optional child context for runtime interaction.
150    ///
151    /// Available when running inside a Component's execution context.
152    /// `None` in standalone or test scenarios.
153    pub fn child_ctx(&self) -> Option<&dyn ChildContext> {
154        self.child_ctx
155    }
156}
157
158impl std::fmt::Debug for ToolContext<'_> {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        f.debug_struct("ToolContext")
161            .field("sandbox_root", &self.sandbox.root())
162            .field("has_child_ctx", &self.child_ctx.is_some())
163            .finish()
164    }
165}
166
167// ─── RustTool ──────────────────────────────────────────────────────
168
169/// A self-describing, sandboxed tool that integrates with `IntentRegistry`.
170///
171/// Each implementor defines:
172/// - **Identity**: name + description + parameter schema (for LLM tools array)
173/// - **Permission**: required capability + read-only flag (for dispatcher)
174/// - **Logic**: execute function (sandboxed, capability pre-checked)
175///
176/// # Pre-conditions for `execute()`
177///
178/// The dispatcher guarantees these before calling `execute()`:
179/// 1. `required_capability()` is granted to the caller
180/// 2. Mutation approval obtained (for non-`is_read_only()` tools)
181///
182/// Tools should NOT re-check capabilities. They may use
183/// `ctx.sandbox()` for path validation and `ctx.child_ctx()`
184/// for command-level permission checks (e.g., `exec`).
185pub trait RustTool: Send + Sync {
186    /// Unique tool name (e.g., "read", "write", "grep").
187    ///
188    /// Must be unique within the `IntentRegistry`.
189    fn name(&self) -> &str;
190
191    /// Human-readable description (sent to LLM as tool description).
192    fn description(&self) -> &str;
193
194    /// JSON Schema for parameters (sent to LLM as tool parameters).
195    ///
196    /// Must be a valid JSON Schema object with `"type": "object"`.
197    fn parameters_schema(&self) -> serde_json::Value;
198
199    /// Required capability for execution.
200    ///
201    /// The dispatcher checks this BEFORE calling `execute()`.
202    fn required_capability(&self) -> Capability;
203
204    /// Whether this tool is read-only.
205    ///
206    /// Read-only tools are exempt from mutation approval (HIL gate).
207    /// Examples: `read`, `grep`, `glob`.
208    fn is_read_only(&self) -> bool;
209
210    /// Execute the tool with the given arguments.
211    ///
212    /// # Arguments
213    ///
214    /// - `args` — JSON object matching [`parameters_schema()`](Self::parameters_schema)
215    /// - `ctx` — Execution context with sandbox and optional child context
216    ///
217    /// # Returns
218    ///
219    /// JSON value representing the tool output (structure is tool-specific).
220    fn execute(
221        &self,
222        args: serde_json::Value,
223        ctx: &ToolContext<'_>,
224    ) -> Result<serde_json::Value, ToolError>;
225
226    /// Generate an [`IntentDef`] for this tool.
227    ///
228    /// Used by `IntentRegistry` to register the tool and expose it
229    /// to LLM tool_calls.
230    ///
231    /// Default implementation uses [`name()`](Self::name),
232    /// [`description()`](Self::description), and
233    /// [`parameters_schema()`](Self::parameters_schema)
234    /// with `IntentResolver::Internal`.
235    fn intent_def(&self) -> IntentDef {
236        IntentDef {
237            name: self.name().to_string(),
238            description: self.description().to_string(),
239            parameters: self.parameters_schema(),
240            resolver: IntentResolver::Internal,
241        }
242    }
243}
244
245// ─── Object Safety ─────────────────────────────────────────────────
246
247// Verify RustTool is object-safe at compile time.
248const _: () = {
249    fn _assert_object_safe(_: &dyn RustTool) {}
250};
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use serde_json::json;
256
257    // ── Stub SandboxPolicy for tests ────────────────────────────
258
259    #[derive(Debug)]
260    struct StubSandbox {
261        root: std::path::PathBuf,
262    }
263
264    impl StubSandbox {
265        fn new(root: &str) -> Self {
266            Self {
267                root: std::path::PathBuf::from(root),
268            }
269        }
270    }
271
272    impl SandboxPolicy for StubSandbox {
273        fn project_root(&self) -> &std::path::Path {
274            &self.root
275        }
276        fn root(&self) -> &std::path::Path {
277            &self.root
278        }
279        fn validate_read(&self, path: &str) -> Result<std::path::PathBuf, orcs_auth::SandboxError> {
280            Ok(self.root.join(path))
281        }
282        fn validate_write(
283            &self,
284            path: &str,
285        ) -> Result<std::path::PathBuf, orcs_auth::SandboxError> {
286            Ok(self.root.join(path))
287        }
288    }
289
290    // ── Stub RustTool for tests ─────────────────────────────────
291
292    struct EchoTool;
293
294    impl RustTool for EchoTool {
295        fn name(&self) -> &str {
296            "echo"
297        }
298        fn description(&self) -> &str {
299            "Echo back the input"
300        }
301        fn parameters_schema(&self) -> serde_json::Value {
302            json!({
303                "type": "object",
304                "properties": {
305                    "message": { "type": "string", "description": "Message to echo" }
306                },
307                "required": ["message"]
308            })
309        }
310        fn required_capability(&self) -> Capability {
311            Capability::READ
312        }
313        fn is_read_only(&self) -> bool {
314            true
315        }
316        fn execute(
317            &self,
318            args: serde_json::Value,
319            _ctx: &ToolContext<'_>,
320        ) -> Result<serde_json::Value, ToolError> {
321            let msg = args["message"]
322                .as_str()
323                .ok_or_else(|| ToolError::new("missing 'message'"))?;
324            Ok(json!({ "echoed": msg }))
325        }
326    }
327
328    // ── Tests ───────────────────────────────────────────────────
329
330    #[test]
331    fn tool_identity() {
332        let tool = EchoTool;
333        assert_eq!(tool.name(), "echo");
334        assert_eq!(tool.description(), "Echo back the input");
335        assert!(tool.is_read_only());
336        assert_eq!(tool.required_capability(), Capability::READ);
337    }
338
339    #[test]
340    fn tool_parameters_schema_is_object() {
341        let tool = EchoTool;
342        let schema = tool.parameters_schema();
343        assert_eq!(
344            schema["type"], "object",
345            "parameters_schema must have type=object"
346        );
347        assert!(
348            schema["properties"]["message"].is_object(),
349            "should define 'message' property"
350        );
351    }
352
353    #[test]
354    fn tool_execute_success() {
355        let tool = EchoTool;
356        let sandbox = StubSandbox::new("/project");
357        let ctx = ToolContext::new(&sandbox);
358
359        let result = tool.execute(json!({"message": "hello"}), &ctx);
360        let value = result.expect("should succeed");
361        assert_eq!(value["echoed"], "hello");
362    }
363
364    #[test]
365    fn tool_execute_missing_arg() {
366        let tool = EchoTool;
367        let sandbox = StubSandbox::new("/project");
368        let ctx = ToolContext::new(&sandbox);
369
370        let result = tool.execute(json!({}), &ctx);
371        let err = result.expect_err("should fail with missing arg");
372        assert!(
373            err.message().contains("missing"),
374            "error should mention missing arg, got: {}",
375            err.message()
376        );
377    }
378
379    #[test]
380    fn tool_intent_def_default() {
381        let tool = EchoTool;
382        let def = tool.intent_def();
383
384        assert_eq!(def.name, "echo");
385        assert_eq!(def.description, "Echo back the input");
386        assert_eq!(def.resolver, IntentResolver::Internal);
387        assert_eq!(def.parameters["type"], "object");
388    }
389
390    #[test]
391    fn tool_error_from_string() {
392        let err = ToolError::from("something went wrong".to_string());
393        assert_eq!(err.message(), "something went wrong");
394        assert_eq!(err.to_string(), "something went wrong");
395    }
396
397    #[test]
398    fn tool_error_from_io() {
399        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
400        let err = ToolError::from(io_err);
401        assert!(
402            err.message().contains("file not found"),
403            "got: {}",
404            err.message()
405        );
406    }
407
408    #[test]
409    fn tool_error_from_sandbox() {
410        let sandbox_err = orcs_auth::SandboxError::OutsideBoundary {
411            path: "/etc/passwd".into(),
412            root: "/project".into(),
413        };
414        let err = ToolError::from(sandbox_err);
415        assert!(
416            err.message().contains("/etc/passwd"),
417            "got: {}",
418            err.message()
419        );
420    }
421
422    #[test]
423    fn tool_error_clone() {
424        let err = ToolError::new("test");
425        let cloned = err.clone();
426        assert_eq!(err.message(), cloned.message());
427    }
428
429    #[test]
430    fn tool_context_debug() {
431        let sandbox = StubSandbox::new("/project");
432        let ctx = ToolContext::new(&sandbox);
433        let debug = format!("{:?}", ctx);
434        assert!(debug.contains("ToolContext"), "got: {debug}");
435        assert!(debug.contains("/project"), "got: {debug}");
436        assert!(debug.contains("has_child_ctx"), "got: {debug}");
437    }
438
439    #[test]
440    fn tool_context_without_child_ctx() {
441        let sandbox = StubSandbox::new("/project");
442        let ctx = ToolContext::new(&sandbox);
443        assert!(ctx.child_ctx().is_none());
444        assert_eq!(ctx.sandbox().root(), std::path::Path::new("/project"));
445    }
446
447    #[test]
448    fn tool_object_safety() {
449        let tool: Box<dyn RustTool> = Box::new(EchoTool);
450        assert_eq!(tool.name(), "echo");
451
452        let sandbox = StubSandbox::new("/project");
453        let ctx = ToolContext::new(&sandbox);
454        let result = tool.execute(json!({"message": "dyn dispatch"}), &ctx);
455        assert!(result.is_ok());
456    }
457
458    #[test]
459    fn tool_arc_dyn() {
460        let tool: std::sync::Arc<dyn RustTool> = std::sync::Arc::new(EchoTool);
461        let clone = std::sync::Arc::clone(&tool);
462        assert_eq!(tool.name(), clone.name());
463    }
464
465    // ── Mutation tool test ──────────────────────────────────────
466
467    struct WriteTool;
468
469    impl RustTool for WriteTool {
470        fn name(&self) -> &str {
471            "write_stub"
472        }
473        fn description(&self) -> &str {
474            "Stub write tool"
475        }
476        fn parameters_schema(&self) -> serde_json::Value {
477            json!({
478                "type": "object",
479                "properties": {
480                    "path": { "type": "string" },
481                    "content": { "type": "string" }
482                },
483                "required": ["path", "content"]
484            })
485        }
486        fn required_capability(&self) -> Capability {
487            Capability::WRITE
488        }
489        fn is_read_only(&self) -> bool {
490            false
491        }
492        fn execute(
493            &self,
494            args: serde_json::Value,
495            ctx: &ToolContext<'_>,
496        ) -> Result<serde_json::Value, ToolError> {
497            let path = args["path"]
498                .as_str()
499                .ok_or_else(|| ToolError::new("missing 'path'"))?;
500            let _target = ctx.sandbox().validate_write(path)?;
501            Ok(json!({ "bytes_written": 42 }))
502        }
503    }
504
505    #[test]
506    fn mutation_tool_not_read_only() {
507        let tool = WriteTool;
508        assert!(!tool.is_read_only());
509        assert_eq!(tool.required_capability(), Capability::WRITE);
510    }
511
512    #[test]
513    fn mutation_tool_intent_def() {
514        let tool = WriteTool;
515        let def = tool.intent_def();
516        assert_eq!(def.name, "write_stub");
517        assert_eq!(def.resolver, IntentResolver::Internal);
518    }
519}