agent_sdk/primitive_tools/
read.rs1use 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
10const MAX_TOKENS: usize = 25_000;
12const CHARS_PER_TOKEN: usize = 4;
13
14pub 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 #[serde(alias = "file_path")]
32 path: String,
33 #[serde(default)]
35 offset: Option<usize>,
36 #[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 if !self.ctx.capabilities.can_read(&path) {
84 return Ok(ToolResult::error(format!(
85 "Permission denied: cannot read '{path}'"
86 )));
87 }
88
89 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 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 let content = self
117 .ctx
118 .environment
119 .read_file(&path)
120 .await
121 .context("Failed to read file")?;
122
123 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); let selected_lines: Vec<&str> = lines.iter().copied().skip(offset).collect();
131
132 let limit = if let Some(user_limit) = input.limit {
134 user_limit
135 } else {
136 let selected_content_len: usize =
138 selected_lines.iter().map(|line| line.len() + 1).sum(); let estimated_tokens = selected_content_len / CHARS_PER_TOKEN;
140
141 if estimated_tokens > MAX_TOKENS {
142 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
183fn 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; if total_chars + line_chars > max_chars {
192 break;
193 }
194 total_chars += line_chars;
195 line_count += 1;
196 }
197
198 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 #[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")); assert!(!result.output.contains("\tline 2")); 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 #[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 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 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 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 let result = tool
397 .execute(&tool_ctx(), json!({"path": "/workspace/src/main.rs"}))
398 .await?;
399 assert!(result.success);
400
401 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 #[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 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 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 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 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 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 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", "another short line", "x".repeat(100).leak(), ];
586
587 let count = estimate_lines_for_tokens(&lines, 10);
589 assert_eq!(count, 2);
590
591 let count = estimate_lines_for_tokens(&lines, 1);
593 assert_eq!(count, 1);
594 }
595}