agent_sdk/primitive_tools/
read.rs

1use crate::{Environment, Tool, ToolContext, ToolResult, ToolTier};
2use anyhow::{Context, Result};
3use async_trait::async_trait;
4use serde::Deserialize;
5use serde_json::{Value, json};
6use std::sync::Arc;
7
8use super::PrimitiveToolContext;
9
10/// Maximum tokens allowed per file read (approximately 4 chars per token)
11const MAX_TOKENS: usize = 25_000;
12const CHARS_PER_TOKEN: usize = 4;
13
14/// Tool for reading file contents
15pub struct ReadTool<E: Environment> {
16    ctx: PrimitiveToolContext<E>,
17}
18
19impl<E: Environment> ReadTool<E> {
20    #[must_use]
21    pub const fn new(environment: Arc<E>, capabilities: crate::AgentCapabilities) -> Self {
22        Self {
23            ctx: PrimitiveToolContext::new(environment, capabilities),
24        }
25    }
26}
27
28#[derive(Debug, Deserialize)]
29struct ReadInput {
30    /// Path to the file to read (also accepts `file_path` for compatibility)
31    #[serde(alias = "file_path")]
32    path: String,
33    /// Optional line offset to start from (1-based)
34    #[serde(default)]
35    offset: Option<usize>,
36    /// Optional number of lines to read
37    #[serde(default)]
38    limit: Option<usize>,
39}
40
41#[async_trait]
42impl<E: Environment + 'static> Tool<()> for ReadTool<E> {
43    fn name(&self) -> &'static str {
44        "read"
45    }
46
47    fn description(&self) -> &'static str {
48        "Read file contents. Can optionally specify offset and limit for large files."
49    }
50
51    fn tier(&self) -> ToolTier {
52        ToolTier::Observe
53    }
54
55    fn input_schema(&self) -> Value {
56        json!({
57            "type": "object",
58            "properties": {
59                "path": {
60                    "type": "string",
61                    "description": "Path to the file to read"
62                },
63                "offset": {
64                    "type": "integer",
65                    "description": "Line number to start from (1-based). Optional."
66                },
67                "limit": {
68                    "type": "integer",
69                    "description": "Number of lines to read. Optional."
70                }
71            },
72            "required": ["path"]
73        })
74    }
75
76    async fn execute(&self, _ctx: &ToolContext<()>, input: Value) -> Result<ToolResult> {
77        let input: ReadInput =
78            serde_json::from_value(input).context("Invalid input for read tool")?;
79
80        let path = self.ctx.environment.resolve_path(&input.path);
81
82        // Check capabilities
83        if !self.ctx.capabilities.can_read(&path) {
84            return Ok(ToolResult::error(format!(
85                "Permission denied: cannot read '{path}'"
86            )));
87        }
88
89        // Check if file exists
90        let exists = self
91            .ctx
92            .environment
93            .exists(&path)
94            .await
95            .context("Failed to check file existence")?;
96
97        if !exists {
98            return Ok(ToolResult::error(format!("File not found: '{path}'")));
99        }
100
101        // Check if it's a directory
102        let is_dir = self
103            .ctx
104            .environment
105            .is_dir(&path)
106            .await
107            .context("Failed to check if path is directory")?;
108
109        if is_dir {
110            return Ok(ToolResult::error(format!(
111                "'{path}' is a directory, not a file"
112            )));
113        }
114
115        // Read file
116        let content = self
117            .ctx
118            .environment
119            .read_file(&path)
120            .await
121            .context("Failed to read file")?;
122
123        // Apply offset and limit if specified
124        let lines: Vec<&str> = content.lines().collect();
125        let total_lines = lines.len();
126
127        let offset = input.offset.unwrap_or(1).saturating_sub(1); // Convert to 0-based
128
129        // Calculate the content that would be returned
130        let selected_lines: Vec<&str> = lines.iter().copied().skip(offset).collect();
131
132        // Check if user specified a limit, otherwise we need to check token limit
133        let limit = if let Some(user_limit) = input.limit {
134            user_limit
135        } else {
136            // Estimate tokens for the selected content
137            let selected_content_len: usize =
138                selected_lines.iter().map(|line| line.len() + 1).sum(); // +1 for newline
139            let estimated_tokens = selected_content_len / CHARS_PER_TOKEN;
140
141            if estimated_tokens > MAX_TOKENS {
142                // File exceeds token limit, return helpful message
143                let suggested_limit = estimate_lines_for_tokens(&selected_lines, MAX_TOKENS);
144                return Ok(ToolResult::success(format!(
145                    "File too large to read at once (~{estimated_tokens} tokens, max {MAX_TOKENS}).\n\
146                     Total lines: {total_lines}\n\n\
147                     Use 'offset' and 'limit' parameters to read specific portions.\n\
148                     Suggested: Start with offset=1, limit={suggested_limit} to read the first ~{MAX_TOKENS} tokens.\n\n\
149                     Example: {{\"path\": \"{path}\", \"offset\": 1, \"limit\": {suggested_limit}}}"
150                )));
151            }
152            selected_lines.len()
153        };
154
155        let selected_lines: Vec<String> = lines
156            .into_iter()
157            .skip(offset)
158            .take(limit)
159            .enumerate()
160            .map(|(i, line)| format!("{:>6}\t{}", offset + i + 1, line))
161            .collect();
162
163        let output = if selected_lines.is_empty() {
164            "(empty file)".to_string()
165        } else {
166            let header = if input.offset.is_some() || input.limit.is_some() {
167                format!(
168                    "Showing lines {}-{} of {} total\n",
169                    offset + 1,
170                    (offset + selected_lines.len()).min(total_lines),
171                    total_lines
172                )
173            } else {
174                String::new()
175            };
176            format!("{header}{}", selected_lines.join("\n"))
177        };
178
179        Ok(ToolResult::success(output))
180    }
181}
182
183/// Estimate how many lines can fit within a token budget
184fn estimate_lines_for_tokens(lines: &[&str], max_tokens: usize) -> usize {
185    let max_chars = max_tokens * CHARS_PER_TOKEN;
186    let mut total_chars = 0;
187    let mut line_count = 0;
188
189    for line in lines {
190        let line_chars = line.len() + 1; // +1 for newline
191        if total_chars + line_chars > max_chars {
192            break;
193        }
194        total_chars += line_chars;
195        line_count += 1;
196    }
197
198    // Return at least 1 to avoid suggesting limit=0
199    line_count.max(1)
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::{AgentCapabilities, InMemoryFileSystem};
206
207    fn create_test_tool(
208        fs: Arc<InMemoryFileSystem>,
209        capabilities: AgentCapabilities,
210    ) -> ReadTool<InMemoryFileSystem> {
211        ReadTool::new(fs, capabilities)
212    }
213
214    fn tool_ctx() -> ToolContext<()> {
215        ToolContext::new(())
216    }
217
218    // ===================
219    // Unit Tests
220    // ===================
221
222    #[tokio::test]
223    async fn test_read_entire_file() -> anyhow::Result<()> {
224        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
225        fs.write_file("test.txt", "line 1\nline 2\nline 3").await?;
226
227        let tool = create_test_tool(fs, AgentCapabilities::full_access());
228        let result = tool
229            .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
230            .await?;
231
232        assert!(result.success);
233        assert!(result.output.contains("line 1"));
234        assert!(result.output.contains("line 2"));
235        assert!(result.output.contains("line 3"));
236        Ok(())
237    }
238
239    #[tokio::test]
240    async fn test_read_with_offset() -> anyhow::Result<()> {
241        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
242        fs.write_file("test.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
243            .await?;
244
245        let tool = create_test_tool(fs, AgentCapabilities::full_access());
246        let result = tool
247            .execute(
248                &tool_ctx(),
249                json!({"path": "/workspace/test.txt", "offset": 3}),
250            )
251            .await?;
252
253        assert!(result.success);
254        assert!(result.output.contains("Showing lines 3-5 of 5 total"));
255        assert!(result.output.contains("line 3"));
256        assert!(result.output.contains("line 4"));
257        assert!(result.output.contains("line 5"));
258        assert!(!result.output.contains("\tline 1")); // Should not include line 1
259        assert!(!result.output.contains("\tline 2")); // Should not include line 2
260        Ok(())
261    }
262
263    #[tokio::test]
264    async fn test_read_with_limit() -> anyhow::Result<()> {
265        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
266        fs.write_file("test.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
267            .await?;
268
269        let tool = create_test_tool(fs, AgentCapabilities::full_access());
270        let result = tool
271            .execute(
272                &tool_ctx(),
273                json!({"path": "/workspace/test.txt", "limit": 2}),
274            )
275            .await?;
276
277        assert!(result.success);
278        assert!(result.output.contains("Showing lines 1-2 of 5 total"));
279        assert!(result.output.contains("line 1"));
280        assert!(result.output.contains("line 2"));
281        assert!(!result.output.contains("\tline 3"));
282        Ok(())
283    }
284
285    #[tokio::test]
286    async fn test_read_with_offset_and_limit() -> anyhow::Result<()> {
287        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
288        fs.write_file("test.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
289            .await?;
290
291        let tool = create_test_tool(fs, AgentCapabilities::full_access());
292        let result = tool
293            .execute(
294                &tool_ctx(),
295                json!({"path": "/workspace/test.txt", "offset": 2, "limit": 2}),
296            )
297            .await?;
298
299        assert!(result.success);
300        assert!(result.output.contains("Showing lines 2-3 of 5 total"));
301        assert!(result.output.contains("line 2"));
302        assert!(result.output.contains("line 3"));
303        assert!(!result.output.contains("\tline 1"));
304        assert!(!result.output.contains("\tline 4"));
305        Ok(())
306    }
307
308    #[tokio::test]
309    async fn test_read_nonexistent_file() -> anyhow::Result<()> {
310        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
311
312        let tool = create_test_tool(fs, AgentCapabilities::full_access());
313        let result = tool
314            .execute(&tool_ctx(), json!({"path": "/workspace/nonexistent.txt"}))
315            .await?;
316
317        assert!(!result.success);
318        assert!(result.output.contains("File not found"));
319        Ok(())
320    }
321
322    #[tokio::test]
323    async fn test_read_directory_returns_error() -> anyhow::Result<()> {
324        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
325        fs.create_dir("/workspace/subdir").await?;
326
327        let tool = create_test_tool(fs, AgentCapabilities::full_access());
328        let result = tool
329            .execute(&tool_ctx(), json!({"path": "/workspace/subdir"}))
330            .await?;
331
332        assert!(!result.success);
333        assert!(result.output.contains("is a directory"));
334        Ok(())
335    }
336
337    // ===================
338    // Integration Tests
339    // ===================
340
341    #[tokio::test]
342    async fn test_read_permission_denied() -> anyhow::Result<()> {
343        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
344        fs.write_file("secret.txt", "secret content").await?;
345
346        // Create read-only capabilities that deny all paths
347        let caps = AgentCapabilities::none();
348
349        let tool = create_test_tool(fs, caps);
350        let result = tool
351            .execute(&tool_ctx(), json!({"path": "/workspace/secret.txt"}))
352            .await?;
353
354        assert!(!result.success);
355        assert!(result.output.contains("Permission denied"));
356        Ok(())
357    }
358
359    #[tokio::test]
360    async fn test_read_denied_path_via_capabilities() -> anyhow::Result<()> {
361        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
362        fs.write_file("secrets/api_key.txt", "API_KEY=secret")
363            .await?;
364
365        // Custom capabilities that deny secrets directory with absolute path pattern
366        let caps =
367            AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/secrets/**".into()]);
368
369        let tool = create_test_tool(fs, caps);
370        let result = tool
371            .execute(
372                &tool_ctx(),
373                json!({"path": "/workspace/secrets/api_key.txt"}),
374            )
375            .await?;
376
377        assert!(!result.success);
378        assert!(result.output.contains("Permission denied"));
379        Ok(())
380    }
381
382    #[tokio::test]
383    async fn test_read_allowed_path_restriction() -> anyhow::Result<()> {
384        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
385        fs.write_file("src/main.rs", "fn main() {}").await?;
386        fs.write_file("config/settings.toml", "key = value").await?;
387
388        // Only allow reading from src/
389        let caps = AgentCapabilities::read_only()
390            .with_denied_paths(vec![])
391            .with_allowed_paths(vec!["/workspace/src/**".into()]);
392
393        let tool = create_test_tool(Arc::clone(&fs), caps.clone());
394
395        // Should be able to read from src/
396        let result = tool
397            .execute(&tool_ctx(), json!({"path": "/workspace/src/main.rs"}))
398            .await?;
399        assert!(result.success);
400
401        // Should NOT be able to read from config/
402        let tool = create_test_tool(fs, caps);
403        let result = tool
404            .execute(
405                &tool_ctx(),
406                json!({"path": "/workspace/config/settings.toml"}),
407            )
408            .await?;
409        assert!(!result.success);
410        assert!(result.output.contains("Permission denied"));
411        Ok(())
412    }
413
414    // ===================
415    // Edge Cases
416    // ===================
417
418    #[tokio::test]
419    async fn test_read_empty_file() -> anyhow::Result<()> {
420        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
421        fs.write_file("empty.txt", "").await?;
422
423        let tool = create_test_tool(fs, AgentCapabilities::full_access());
424        let result = tool
425            .execute(&tool_ctx(), json!({"path": "/workspace/empty.txt"}))
426            .await?;
427
428        assert!(result.success);
429        assert!(result.output.contains("(empty file)"));
430        Ok(())
431    }
432
433    #[tokio::test]
434    async fn test_read_large_file_with_pagination() -> anyhow::Result<()> {
435        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
436
437        // Create a file with 100 lines
438        let content: String = (1..=100)
439            .map(|i| format!("line {i}"))
440            .collect::<Vec<_>>()
441            .join("\n");
442        fs.write_file("large.txt", &content).await?;
443
444        let tool = create_test_tool(fs, AgentCapabilities::full_access());
445
446        // Read lines 50-60
447        let result = tool
448            .execute(
449                &tool_ctx(),
450                json!({"path": "/workspace/large.txt", "offset": 50, "limit": 10}),
451            )
452            .await?;
453
454        assert!(result.success);
455        assert!(result.output.contains("Showing lines 50-59 of 100 total"));
456        assert!(result.output.contains("line 50"));
457        assert!(result.output.contains("line 59"));
458        assert!(!result.output.contains("\tline 49"));
459        assert!(!result.output.contains("\tline 60"));
460        Ok(())
461    }
462
463    #[tokio::test]
464    async fn test_read_offset_beyond_file_length() -> anyhow::Result<()> {
465        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
466        fs.write_file("short.txt", "line 1\nline 2").await?;
467
468        let tool = create_test_tool(fs, AgentCapabilities::full_access());
469        let result = tool
470            .execute(
471                &tool_ctx(),
472                json!({"path": "/workspace/short.txt", "offset": 100}),
473            )
474            .await?;
475
476        assert!(result.success);
477        assert!(result.output.contains("(empty file)"));
478        Ok(())
479    }
480
481    #[tokio::test]
482    async fn test_read_file_with_special_characters() -> anyhow::Result<()> {
483        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
484        let content = "特殊字符\néàü\n🎉emoji\ntab\there";
485        fs.write_file("special.txt", content).await?;
486
487        let tool = create_test_tool(fs, AgentCapabilities::full_access());
488        let result = tool
489            .execute(&tool_ctx(), json!({"path": "/workspace/special.txt"}))
490            .await?;
491
492        assert!(result.success);
493        assert!(result.output.contains("特殊字符"));
494        assert!(result.output.contains("éàü"));
495        assert!(result.output.contains("🎉emoji"));
496        Ok(())
497    }
498
499    #[tokio::test]
500    async fn test_read_tool_metadata() {
501        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
502        let tool = create_test_tool(fs, AgentCapabilities::full_access());
503
504        assert_eq!(tool.name(), "read");
505        assert_eq!(tool.tier(), ToolTier::Observe);
506        assert!(tool.description().contains("Read"));
507
508        let schema = tool.input_schema();
509        assert!(schema.get("properties").is_some());
510        assert!(schema["properties"].get("path").is_some());
511    }
512
513    #[tokio::test]
514    async fn test_read_invalid_input() -> anyhow::Result<()> {
515        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
516        let tool = create_test_tool(fs, AgentCapabilities::full_access());
517
518        // Missing required path field
519        let result = tool.execute(&tool_ctx(), json!({})).await;
520
521        assert!(result.is_err());
522        Ok(())
523    }
524
525    #[tokio::test]
526    async fn test_read_large_file_exceeds_token_limit() -> anyhow::Result<()> {
527        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
528
529        // Create a file that exceeds 25k tokens (~100k chars)
530        // Each line is ~100 chars, need ~1000 lines to exceed limit
531        let line = "x".repeat(100);
532        let content: String = (1..=1500)
533            .map(|i| format!("{i}: {line}"))
534            .collect::<Vec<_>>()
535            .join("\n");
536        fs.write_file("huge.txt", &content).await?;
537
538        let tool = create_test_tool(fs, AgentCapabilities::full_access());
539        let result = tool
540            .execute(&tool_ctx(), json!({"path": "/workspace/huge.txt"}))
541            .await?;
542
543        assert!(result.success);
544        assert!(result.output.contains("File too large to read at once"));
545        assert!(result.output.contains("Total lines: 1500"));
546        assert!(result.output.contains("offset"));
547        assert!(result.output.contains("limit"));
548        Ok(())
549    }
550
551    #[tokio::test]
552    async fn test_read_large_file_with_explicit_limit_bypasses_check() -> anyhow::Result<()> {
553        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
554
555        // Create a large file
556        let line = "x".repeat(100);
557        let content: String = (1..=1500)
558            .map(|i| format!("{i}: {line}"))
559            .collect::<Vec<_>>()
560            .join("\n");
561        fs.write_file("huge.txt", &content).await?;
562
563        let tool = create_test_tool(fs, AgentCapabilities::full_access());
564
565        // With explicit limit, should return the requested lines
566        let result = tool
567            .execute(
568                &tool_ctx(),
569                json!({"path": "/workspace/huge.txt", "offset": 1, "limit": 10}),
570            )
571            .await?;
572
573        assert!(result.success);
574        assert!(result.output.contains("Showing lines 1-10 of 1500 total"));
575        assert!(!result.output.contains("File too large"));
576        Ok(())
577    }
578
579    #[test]
580    fn test_estimate_lines_for_tokens() {
581        let lines: Vec<&str> = vec![
582            "short line",           // 11 chars
583            "another short line",   // 19 chars
584            "x".repeat(100).leak(), // 100 chars
585        ];
586
587        // With 10 tokens (40 chars), should fit first 2 lines (11 + 19 = 30 chars)
588        let count = estimate_lines_for_tokens(&lines, 10);
589        assert_eq!(count, 2);
590
591        // With 1 token (4 chars), should return at least 1
592        let count = estimate_lines_for_tokens(&lines, 1);
593        assert_eq!(count, 1);
594    }
595}