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