Skip to main content

phi_core/tools/
file.rs

1//! File tools — read and write files with safety limits.
2/*
3ARCHITECTURE: ReadFileTool + WriteFileTool — the agent's eyes and hands on disk
4
5`ReadFileTool` reads files and returns their content as text (or base64 for images).
6`WriteFileTool` creates or overwrites files.
7
8Safety limits:
9  - `max_bytes` on ReadFileTool prevents OOM when reading huge files
10  - `allowed_paths` restricts which directories can be accessed (empty = no restriction)
11
12Image support:
13  Reading `.jpg/.png/.webp/.gif/.bmp` files returns `Content::Image` (base64-encoded)
14  so the LLM can "see" screenshots, diagrams, and mockups inline.
15
16Design: return errors as ToolError (not panic or ProviderError)
17  `ToolError::Failed(msg)` tells the agent loop to feed the error text back to the LLM.
18  The LLM can then decide how to recover (try a different path, create the directory, etc.)
19*/
20
21use crate::types::*;
22use async_trait::async_trait;
23use base64::Engine;
24use std::path::Path;
25
26/// 20 MB limit for image files
27const MAX_IMAGE_SIZE_BYTES: u64 = 20 * 1024 * 1024;
28
29/// Returns true if the path has a recognized image file extension.
30/*
31RUST QUIRK: `matches!(expr, pattern)` — compact pattern matching returning bool
32
33`matches!(path.extension()...., Some("jpg" | "jpeg" | "png" | ...))` is shorthand for:
34  match path.extension().... {
35      Some("jpg") | Some("jpeg") | Some("png") | ... => true,
36      _ => false,
37  }
38
39The `|` inside `Some(...)` is an "or-pattern" — it matches any of the listed values.
40This requires Rust 1.53+ (stable since then). Prior versions required separate `|` arms.
41
42`.extension()` returns `Option<&OsStr>` (OS-native string, may not be UTF-8).
43`.and_then(|e| e.to_str())` tries to convert to `&str` — returns None for non-UTF-8 paths.
44`.map(|e| e.to_lowercase())` normalizes case: "PNG" and "png" both work.
45`.as_deref()` converts `Option<String>` → `Option<&str>` for the pattern match.
46*/
47fn is_image_file(path: &Path) -> bool {
48    matches!(
49        path.extension()
50            .and_then(|e| e.to_str())
51            .map(|e| e.to_lowercase())
52            .as_deref(),
53        Some("jpg" | "jpeg" | "png" | "webp" | "gif" | "bmp")
54    )
55}
56
57/// Returns the MIME type for recognized image extensions.
58/*
59RUST QUIRK: `Option<&'static str>` — returning a reference to a string literal
60
61`&'static str` is a reference to a string that lives for the entire program lifetime
62(string literals are baked into the binary). Returning `&'static str` from this function
63is safe because "image/jpeg" etc. are compile-time constants — they always exist.
64If we returned `Option<String>`, we'd heap-allocate a new String each call (wasteful).
65*/
66fn get_image_mime_type(path: &Path) -> Option<&'static str> {
67    match path
68        .extension()
69        .and_then(|e| e.to_str())
70        .map(|e| e.to_lowercase())
71        .as_deref()
72    {
73        Some("jpg" | "jpeg") => Some("image/jpeg"),
74        Some("png") => Some("image/png"),
75        Some("webp") => Some("image/webp"),
76        Some("gif") => Some("image/gif"),
77        Some("bmp") => Some("image/bmp"),
78        _ => None,
79    }
80}
81
82/// Read a file's contents. Supports line range for large files.
83pub struct ReadFileTool {
84    /// Max file size to read (prevents OOM)
85    pub max_bytes: usize,
86    /// Allowed directory roots (empty = no restriction)
87    pub allowed_paths: Vec<String>,
88}
89
90impl Default for ReadFileTool {
91    fn default() -> Self {
92        Self {
93            max_bytes: 1024 * 1024, // 1MB
94            allowed_paths: Vec::new(),
95        }
96    }
97}
98
99impl ReadFileTool {
100    pub fn new() -> Self {
101        Self::default()
102    }
103}
104
105#[async_trait]
106impl AgentTool for ReadFileTool {
107    fn name(&self) -> &str {
108        "read_file"
109    }
110
111    fn label(&self) -> &str {
112        "Read File"
113    }
114
115    fn description(&self) -> &str {
116        "Read a file's contents. Supports text files with optional offset/limit, and image files (jpg, png, webp, gif, bmp) which are returned as base64-encoded images."
117    }
118
119    fn parameters_schema(&self) -> serde_json::Value {
120        serde_json::json!({
121            "type": "object",
122            "properties": {
123                "path": {
124                    "type": "string",
125                    "description": "File path to read"
126                },
127                "offset": {
128                    "type": "integer",
129                    "description": "Starting line number (1-indexed, optional)"
130                },
131                "limit": {
132                    "type": "integer",
133                    "description": "Maximum number of lines to return (optional)"
134                }
135            },
136            "required": ["path"]
137        })
138    }
139
140    async fn execute(
141        &self,
142        params: serde_json::Value, // LLM INPUT — expects `{"path", "offset"?, "limit"?}`; offset/limit for partial reads of large text files
143        ctx: ToolContext, // SYSTEM ENV — ctx.cancel checked before disk access; images bypass line-range logic
144    ) -> Result<ToolResult, ToolError> {
145        let path = params["path"]
146            .as_str()
147            .ok_or_else(|| ToolError::InvalidArgs("missing 'path' parameter".into()))?;
148
149        if ctx.cancel.is_cancelled() {
150            return Err(ToolError::Cancelled);
151        }
152
153        // Check file exists and size
154        let metadata = tokio::fs::metadata(path)
155            .await
156            .map_err(|e| ToolError::Failed(format!("Cannot access {}: {}", path, e)))?;
157
158        // Handle image files: read as binary, return base64-encoded Content::Image
159        let file_path = Path::new(path);
160        if is_image_file(file_path) {
161            if metadata.len() > MAX_IMAGE_SIZE_BYTES {
162                return Err(ToolError::Failed(format!(
163                    "Image too large ({}MB, max 20MB)",
164                    metadata.len() / (1024 * 1024)
165                )));
166            }
167            let mime_type = get_image_mime_type(file_path)
168                .ok_or_else(|| ToolError::Failed("Unknown image format".into()))?;
169            let bytes = tokio::fs::read(path)
170                .await
171                .map_err(|e| ToolError::Failed(format!("Cannot read {}: {}", path, e)))?;
172            let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
173            return Ok(ToolResult {
174                content: vec![Content::Image {
175                    data,
176                    mime_type: mime_type.to_string(),
177                }],
178                details: serde_json::json!({ "path": path, "bytes": bytes.len() }),
179                child_loop_id: None,
180            });
181        }
182
183        // Text files: check size limit and apply line offset/limit
184        if metadata.len() as usize > self.max_bytes {
185            return Err(ToolError::Failed(format!(
186                "File too large ({} bytes, max {}). Use offset/limit for partial reads.",
187                metadata.len(),
188                self.max_bytes
189            )));
190        }
191
192        let content = tokio::fs::read_to_string(path)
193            .await
194            .map_err(|e| ToolError::Failed(format!("Cannot read {}: {}", path, e)))?;
195
196        let offset = params["offset"].as_u64().map(|v| v.max(1) as usize);
197        let limit = params["limit"].as_u64().map(|v| v as usize);
198
199        // Always show line numbers — helps agent reference exact lines for edit_file
200        let lines: Vec<&str> = content.lines().collect();
201        let total = lines.len();
202
203        let (start, end) = match (offset, limit) {
204            (Some(off), Some(lim)) => {
205                let s = (off - 1).min(total);
206                (s, (s + lim).min(total))
207            }
208            (Some(off), None) => {
209                let s = (off - 1).min(total);
210                (s, total)
211            }
212            (None, Some(lim)) => (0, lim.min(total)),
213            (None, None) => (0, total),
214        };
215
216        let numbered: Vec<String> = lines[start..end]
217            .iter()
218            .enumerate()
219            .map(|(i, line)| format!("{:>4} | {}", start + i + 1, line))
220            .collect();
221
222        let header = if start > 0 || end < total {
223            format!("[Lines {}-{} of {}]", start + 1, end, total)
224        } else {
225            format!("[{} lines]", total)
226        };
227
228        let output = format!("{}\n{}", header, numbered.join("\n"));
229
230        Ok(ToolResult {
231            content: vec![Content::Text { text: output }],
232            details: serde_json::json!({ "path": path }),
233            child_loop_id: None,
234        })
235    }
236}
237
238// ---------------------------------------------------------------------------
239
240/// Write content to a file. Creates parent directories if needed.
241pub struct WriteFileTool;
242
243impl Default for WriteFileTool {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249impl WriteFileTool {
250    pub fn new() -> Self {
251        Self
252    }
253}
254
255#[async_trait]
256impl AgentTool for WriteFileTool {
257    fn name(&self) -> &str {
258        "write_file"
259    }
260
261    fn label(&self) -> &str {
262        "Write File"
263    }
264
265    fn description(&self) -> &str {
266        "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Creates parent directories automatically."
267    }
268
269    fn parameters_schema(&self) -> serde_json::Value {
270        serde_json::json!({
271            "type": "object",
272            "properties": {
273                "path": {
274                    "type": "string",
275                    "description": "File path to write"
276                },
277                "content": {
278                    "type": "string",
279                    "description": "Content to write to the file"
280                }
281            },
282            "required": ["path", "content"]
283        })
284    }
285
286    async fn execute(
287        &self,
288        params: serde_json::Value, // LLM INPUT — expects `{"path", "content"}`; parent directories created automatically
289        ctx: ToolContext, // SYSTEM ENV — ctx.cancel checked before write; no timeout (write is fast)
290    ) -> Result<ToolResult, ToolError> {
291        let path = params["path"]
292            .as_str()
293            .ok_or_else(|| ToolError::InvalidArgs("missing 'path' parameter".into()))?;
294        let content = params["content"]
295            .as_str()
296            .ok_or_else(|| ToolError::InvalidArgs("missing 'content' parameter".into()))?;
297
298        if ctx.cancel.is_cancelled() {
299            return Err(ToolError::Cancelled);
300        }
301
302        // Create parent directories
303        if let Some(parent) = std::path::Path::new(path).parent() {
304            if !parent.exists() {
305                tokio::fs::create_dir_all(parent)
306                    .await
307                    .map_err(|e| ToolError::Failed(format!("Cannot create directory: {}", e)))?;
308            }
309        }
310
311        tokio::fs::write(path, content)
312            .await
313            .map_err(|e| ToolError::Failed(format!("Cannot write {}: {}", path, e)))?;
314
315        let bytes = content.len();
316        Ok(ToolResult {
317            content: vec![Content::Text {
318                text: format!("Wrote {} bytes to {}", bytes, path),
319            }],
320            details: serde_json::json!({ "path": path, "bytes": bytes }),
321            child_loop_id: None,
322        })
323    }
324}