nika_engine/tools/mod.rs
1//! File Tools Module - Claude Code-like filesystem operations
2//!
3//! Provides 5 tools for filesystem interaction:
4//! - [`ReadTool`] - Read files with line numbers
5//! - [`WriteTool`] - Create new files
6//! - [`EditTool`] - Modify existing files (requires read-before-edit)
7//! - [`GlobTool`] - Find files by pattern
8//! - [`GrepTool`] - Search file contents with regex
9//!
10//! # Permission Model
11//!
12//! Inspired by Gemini CLI's Yolo Mode and Claude Code's permission levels:
13//!
14//! | Mode | Behavior |
15//! |------|----------|
16//! | `Deny` | All operations denied |
17//! | `Plan` | Ask before each operation |
18//! | `AcceptEdits` | Auto-approve edits, ask for others |
19//! | `YoloMode` | Auto-approve all (Yolo mode) |
20//!
21//! # Security
22//!
23//! All paths are validated to be:
24//! - Absolute paths only
25//! - Within the working directory (security boundary)
26//!
27//! # Example
28//!
29//! ```rust,no_run
30//! use nika::tools::{ToolContext, ReadTool, PermissionMode};
31//! use std::path::PathBuf;
32//! use std::sync::Arc;
33//!
34//! #[tokio::main]
35//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
36//! let ctx = Arc::new(ToolContext::new(
37//! PathBuf::from("/path/to/project"),
38//! PermissionMode::YoloMode,
39//! ));
40//!
41//! let read_tool = ReadTool::new(ctx);
42//! let result = read_tool.execute(nika::tools::ReadParams {
43//! file_path: "/path/to/project/src/main.rs".to_string(),
44//! offset: None,
45//! limit: None,
46//! }).await?;
47//!
48//! println!("{}", result.content);
49//! Ok(())
50//! }
51//! ```
52
53mod context;
54mod edit;
55mod glob;
56mod grep;
57mod read;
58mod rig_adapter;
59mod submit_tool;
60mod write;
61
62pub use context::{PermissionMode, ToolContext, ToolEvent, ToolOperation};
63pub use edit::{EditParams, EditResult, EditTool};
64pub use glob::{GlobParams, GlobResult, GlobTool};
65pub use grep::{GrepOutputMode, GrepParams, GrepResult, GrepTool};
66pub use read::{ReadParams, ReadResult, ReadTool};
67pub use rig_adapter::{create_rig_file_tools, RigFileTool};
68pub use submit_tool::{DynamicSubmitTool, ToolDefinition};
69pub use write::{WriteParams, WriteResult, WriteTool};
70
71use crate::error::NikaError;
72use async_trait::async_trait;
73use serde::{Deserialize, Serialize};
74use serde_json::Value;
75
76// ═══════════════════════════════════════════════════════════════════════════
77// TOOL TRAIT
78// ═══════════════════════════════════════════════════════════════════════════
79
80/// Trait for file tools that can be called by agents
81///
82/// Implements the rig::ToolDyn pattern for integration with RigAgentLoop.
83#[async_trait]
84pub trait FileTool: Send + Sync {
85 /// Tool name (e.g., "read", "edit", "glob")
86 fn name(&self) -> &'static str;
87
88 /// Tool description for LLM
89 fn description(&self) -> &'static str;
90
91 /// JSON Schema for parameters
92 fn parameters_schema(&self) -> Value;
93
94 /// Execute the tool with JSON parameters
95 async fn call(&self, params: Value) -> Result<ToolOutput, NikaError>;
96}
97
98/// Output from a tool execution
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct ToolOutput {
101 /// Text content of the result
102 pub content: String,
103 /// Whether this is an error response
104 pub is_error: bool,
105 /// Optional structured data
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub data: Option<Value>,
108}
109
110impl ToolOutput {
111 /// Create a successful output
112 pub fn success(content: impl Into<String>) -> Self {
113 Self {
114 content: content.into(),
115 is_error: false,
116 data: None,
117 }
118 }
119
120 /// Create a successful output with data
121 pub fn success_with_data(content: impl Into<String>, data: Value) -> Self {
122 Self {
123 content: content.into(),
124 is_error: false,
125 data: Some(data),
126 }
127 }
128
129 /// Create an error output
130 pub fn error(content: impl Into<String>) -> Self {
131 Self {
132 content: content.into(),
133 is_error: true,
134 data: None,
135 }
136 }
137}
138
139// ═══════════════════════════════════════════════════════════════════════════
140// TOOL ERROR CODES
141// ═══════════════════════════════════════════════════════════════════════════
142
143/// Tool-specific error codes (NIKA-200 range)
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum ToolErrorCode {
146 /// NIKA-200: Read operation failed
147 ReadFailed = 200,
148 /// NIKA-201: Write operation failed
149 WriteFailed = 201,
150 /// NIKA-202: Edit operation failed
151 EditFailed = 202,
152 /// NIKA-203: Must read file before editing
153 MustReadFirst = 203,
154 /// NIKA-204: Path is outside working directory
155 PathOutOfBounds = 204,
156 /// NIKA-205: Permission denied for this operation
157 PermissionDenied = 205,
158 /// NIKA-206: Invalid glob pattern
159 InvalidGlobPattern = 206,
160 /// NIKA-207: Invalid regex pattern
161 InvalidRegex = 207,
162 /// NIKA-208: File not found
163 FileNotFound = 208,
164 /// NIKA-209: old_string not unique in file
165 OldStringNotUnique = 209,
166 /// NIKA-215: File already exists (for write)
167 FileAlreadyExists = 215,
168 /// NIKA-211: Path must be absolute
169 RelativePath = 211,
170}
171
172impl ToolErrorCode {
173 /// Get the error code string
174 pub fn code(&self) -> String {
175 format!("NIKA-{}", *self as u16)
176 }
177}
178
179// ═══════════════════════════════════════════════════════════════════════════
180// TESTS
181// ═══════════════════════════════════════════════════════════════════════════
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn test_tool_output_success() {
189 let output = ToolOutput::success("File read successfully");
190 assert!(!output.is_error);
191 assert_eq!(output.content, "File read successfully");
192 assert!(output.data.is_none());
193 }
194
195 #[test]
196 fn test_tool_output_error() {
197 let output = ToolOutput::error("File not found");
198 assert!(output.is_error);
199 assert_eq!(output.content, "File not found");
200 }
201
202 #[test]
203 fn test_tool_error_codes() {
204 assert_eq!(ToolErrorCode::ReadFailed.code(), "NIKA-200");
205 assert_eq!(ToolErrorCode::MustReadFirst.code(), "NIKA-203");
206 assert_eq!(ToolErrorCode::PathOutOfBounds.code(), "NIKA-204");
207 }
208}