Skip to main content

agent_sdk/primitive_tools/
read.rs

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