agent_sdk/primitive_tools/
read.rs1use 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
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
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 if !self.ctx.capabilities.can_read(&path) {
89 return Ok(ToolResult::error(format!(
90 "Permission denied: cannot read '{path}'"
91 )));
92 }
93
94 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 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 let content = self
122 .ctx
123 .environment
124 .read_file(&path)
125 .await
126 .context("Failed to read file")?;
127
128 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); let selected_lines: Vec<&str> = lines.iter().copied().skip(offset).collect();
136
137 let limit = if let Some(user_limit) = input.limit {
139 user_limit
140 } else {
141 let selected_content_len: usize =
143 selected_lines.iter().map(|line| line.len() + 1).sum(); let estimated_tokens = selected_content_len / CHARS_PER_TOKEN;
145
146 if estimated_tokens > MAX_TOKENS {
147 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 if is_empty {
189 append_reminder(&mut result, builtin::READ_EMPTY_FILE_REMINDER);
190 }
191
192 append_reminder(&mut result, builtin::READ_SECURITY_REMINDER);
194
195 Ok(result)
196 }
197}
198
199fn 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; if total_chars + line_chars > max_chars {
208 break;
209 }
210 total_chars += line_chars;
211 line_count += 1;
212 }
213
214 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 #[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")); assert!(!result.output.contains("\tline 2")); 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 #[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 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 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 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 let result = tool
413 .execute(&tool_ctx(), json!({"path": "/workspace/src/main.rs"}))
414 .await?;
415 assert!(result.success);
416
417 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 #[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 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 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 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 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 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 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", "another short line", "x".repeat(100).leak(), ];
602
603 let count = estimate_lines_for_tokens(&lines, 10);
605 assert_eq!(count, 2);
606
607 let count = estimate_lines_for_tokens(&lines, 1);
609 assert_eq!(count, 1);
610 }
611}