Skip to main content

apollo/tools/
file_ops.rs

1//! File operations tools — Read, Write (matching OpenClaw's API).
2
3use async_trait::async_trait;
4use serde::Deserialize;
5use std::path::PathBuf;
6
7use super::sandbox::{resolve_workspace_existing_path, resolve_workspace_write_path};
8use super::traits::*;
9use crate::text::truncate_chars;
10
11// ============================================================
12// Read tool — read file contents with optional offset/limit
13// ============================================================
14
15pub struct FileReadTool {
16    workspace: PathBuf,
17}
18
19impl FileReadTool {
20    pub fn new(workspace: PathBuf) -> Self {
21        Self { workspace }
22    }
23}
24
25#[derive(Deserialize)]
26struct ReadArgs {
27    #[serde(alias = "file_path")]
28    path: String,
29    /// Line number to start reading from (1-indexed)
30    offset: Option<usize>,
31    /// Maximum number of lines to read
32    limit: Option<usize>,
33}
34
35#[async_trait]
36impl Tool for FileReadTool {
37    fn name(&self) -> &str {
38        "Read"
39    }
40
41    fn spec(&self) -> ToolSpec {
42        ToolSpec {
43            name: "Read".to_string(),
44            description: "Read the contents of a file. Use offset/limit for large files."
45                .to_string(),
46            parameters: serde_json::json!({
47                "type": "object",
48                "properties": {
49                    "path": {
50                        "type": "string",
51                        "description": "Path to the file to read (relative or absolute)"
52                    },
53                    "offset": {
54                        "type": "integer",
55                        "description": "Line number to start reading from (1-indexed)"
56                    },
57                    "limit": {
58                        "type": "integer",
59                        "description": "Maximum number of lines to read"
60                    }
61                },
62                "required": ["path"]
63            }),
64        }
65    }
66
67    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
68        let args: ReadArgs = serde_json::from_str(arguments)?;
69        let full_path = match resolve_workspace_existing_path(&self.workspace, &args.path) {
70            Ok(p) => p,
71            Err(e) => return Ok(ToolResult::error(e.to_string())),
72        };
73
74        match tokio::fs::read_to_string(&full_path).await {
75            Ok(content) => {
76                let lines: Vec<&str> = content.lines().collect();
77                let total_lines = lines.len();
78
79                let offset = args.offset.unwrap_or(1).max(1) - 1; // Convert 1-indexed to 0-indexed
80                let limit = args.limit.unwrap_or(2000);
81
82                let selected: Vec<&str> = lines.iter().skip(offset).take(limit).copied().collect();
83
84                let result = selected.join("\n");
85
86                // Truncate if too large
87                let truncated = if result.len() > 50_000 {
88                    format!(
89                        "{}...\n[truncated at 50KB]",
90                        truncate_chars(&result, 50_000)
91                    )
92                } else {
93                    result
94                };
95
96                let remaining = total_lines.saturating_sub(offset + limit);
97                if remaining > 0 {
98                    Ok(ToolResult::success(format!(
99                        "{}\n\n[{} more lines in file. Use offset={} to continue.]",
100                        truncated,
101                        remaining,
102                        offset + limit + 1
103                    )))
104                } else {
105                    Ok(ToolResult::success(truncated))
106                }
107            }
108            Err(e) => Ok(ToolResult::error(format!(
109                "Cannot read '{}': {}",
110                args.path, e
111            ))),
112        }
113    }
114}
115
116// ============================================================
117// Write tool — create or overwrite files, auto-create parent dirs
118// ============================================================
119
120pub struct FileWriteTool {
121    workspace: PathBuf,
122}
123
124impl FileWriteTool {
125    pub fn new(workspace: PathBuf) -> Self {
126        Self { workspace }
127    }
128}
129
130#[derive(Deserialize)]
131struct WriteArgs {
132    #[serde(alias = "file_path")]
133    path: String,
134    content: String,
135}
136
137#[async_trait]
138impl Tool for FileWriteTool {
139    fn name(&self) -> &str {
140        "Write"
141    }
142
143    fn spec(&self) -> ToolSpec {
144        ToolSpec {
145            name: "Write".to_string(),
146            description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.".to_string(),
147            parameters: serde_json::json!({
148                "type": "object",
149                "properties": {
150                    "path": {
151                        "type": "string",
152                        "description": "Path to the file to write (relative or absolute)"
153                    },
154                    "content": {
155                        "type": "string",
156                        "description": "Content to write to the file"
157                    }
158                },
159                "required": ["path", "content"]
160            }),
161        }
162    }
163
164    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
165        let args: WriteArgs = serde_json::from_str(arguments)?;
166        let full_path = match resolve_workspace_write_path(&self.workspace, &args.path) {
167            Ok(p) => p,
168            Err(e) => return Ok(ToolResult::error(e.to_string())),
169        };
170
171        // Create parent directories
172        if let Some(parent) = full_path.parent() {
173            tokio::fs::create_dir_all(parent).await?;
174        }
175
176        match tokio::fs::write(&full_path, &args.content).await {
177            Ok(_) => Ok(ToolResult::success(format!(
178                "Successfully wrote {} bytes to {}",
179                args.content.len(),
180                args.path
181            ))),
182            Err(e) => Ok(ToolResult::error(format!("Failed to write: {}", e))),
183        }
184    }
185}