Skip to main content

telos_agent/tools/builtin/
file_read.rs

1//! `Read` tool — read a UTF-8 text file with optional line slicing.
2
3use async_trait::async_trait;
4use serde_json::{Value, json};
5
6use crate::error::AgentError;
7use crate::tools::api::FileReadRecord;
8use crate::tools::api::{Tool, ToolContext, ToolDefinition, ToolOutput};
9
10use super::{
11    canonicalize_within_cwd, modified_timestamp_ms, optional_usize_any, required_string_any,
12    resolve_workspace_path,
13};
14
15/// Built-in file-read tool. Read-only; safe to run concurrently.
16pub struct FileReadTool;
17
18#[async_trait]
19impl Tool for FileReadTool {
20    fn definition(&self) -> ToolDefinition {
21        ToolDefinition {
22            name: "Read".into(),
23            description: "Read a UTF-8 text file. Use this before editing an existing file. \
24The returned `content` prefixes each line with its 1-indexed line number for display; \
25provide the original file text (without line-number prefixes) to `Edit` when editing."
26                .into(),
27            input_schema: json!({
28                "type": "object",
29                "properties": {
30                    "file_path": { "type": "string", "description": "Path to the file to read, absolute or relative to cwd." },
31                    "offset": { "type": "integer", "description": "1-indexed line number to start reading from." },
32                    "limit": { "type": "integer", "description": "Maximum number of lines to read." }
33                },
34                "required": ["file_path"]
35            }),
36        }
37    }
38
39    fn prompt_text(&self) -> Option<&'static str> {
40        Some(
41            "Use Read to inspect UTF-8 text files. Always read a file before editing it. \
42Use `offset`/`limit` for large files. Content includes 1-indexed line numbers; provide original text to Edit.",
43        )
44    }
45
46    async fn validate(&self, arguments: &Value, _context: &ToolContext) -> Result<(), AgentError> {
47        required_string_any(arguments, &["file_path", "path"]).map(|_| ())
48    }
49
50    fn is_concurrency_safe(&self, _arguments: &Value) -> bool {
51        true
52    }
53
54    async fn invoke(
55        &self,
56        arguments: Value,
57        context: ToolContext,
58    ) -> Result<ToolOutput, AgentError> {
59        let input_path = required_string_any(&arguments, &["file_path", "path"])?;
60        let path = resolve_workspace_path(&context.cwd, input_path)?;
61        let path = canonicalize_within_cwd(&context.cwd, &path).await?;
62        let metadata =
63            tokio::fs::metadata(&path).await.map_err(|err| AgentError::ToolExecution {
64                tool: "Read".into(),
65                message: friendly_file_error(&context.cwd, &path, err),
66            })?;
67        if metadata.len() > context.max_file_read_bytes as u64 {
68            return Err(AgentError::ToolExecution {
69                tool: "Read".into(),
70                message: format!(
71                    "file exceeds maximum read size ({} bytes): {}",
72                    context.max_file_read_bytes,
73                    path.display()
74                ),
75            });
76        }
77        let bytes = tokio::fs::read(&path).await.map_err(|err| AgentError::ToolExecution {
78            tool: "Read".into(),
79            message: friendly_file_error(&context.cwd, &path, err),
80        })?;
81        if bytes.contains(&0) {
82            return Err(AgentError::ToolExecution {
83                tool: "Read".into(),
84                message: "file appears to be binary; Read only supports UTF-8 text in telos_agent"
85                    .into(),
86            });
87        }
88        let content = String::from_utf8(bytes).map_err(|_| AgentError::ToolExecution {
89            tool: "Read".into(),
90            message: "file is not valid UTF-8 text".into(),
91        })?;
92        let timestamp_ms = modified_timestamp_ms(&path).await?;
93        // `offset` is 1-indexed for human readability; clamp to >= 1.
94        let start_line =
95            optional_usize_any(&arguments, &["offset", "start_line"]).unwrap_or(1).max(1);
96        let max_lines = optional_usize_any(&arguments, &["limit", "max_lines"]);
97        // Prefix every emitted line with its 1-indexed line number — gives the
98        // model an unambiguous anchor for follow-up edits.
99        let lines = content
100            .lines()
101            .enumerate()
102            .skip(start_line.saturating_sub(1))
103            .take(max_lines.unwrap_or(usize::MAX))
104            .map(|(idx, line)| format!("{}: {}", idx + 1, line))
105            .collect::<Vec<_>>();
106        let line_count = content.lines().count();
107        let is_partial_view = start_line > 1 || max_lines.is_some_and(|limit| limit < line_count);
108        context.read_file_state.lock().await.insert(
109            path.clone(),
110            FileReadRecord {
111                content: content.clone(),
112                timestamp_ms,
113                is_partial_view,
114                offset: Some(start_line),
115                limit: max_lines,
116            },
117        );
118
119        let rendered = if content.is_empty() {
120            "<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>".to_string()
121        } else if lines.is_empty() {
122            format!(
123                "<system-reminder>Warning: the file exists but is shorter than the provided offset ({start_line}). The file has {line_count} lines.</system-reminder>"
124            )
125        } else {
126            lines.join("\n")
127        };
128
129        Ok(ToolOutput::json(json!({
130            "file_path": input_path,
131            "path": path,
132            "content": rendered,
133            "start_line": start_line,
134            "total_lines": line_count,
135        })))
136    }
137}
138
139fn friendly_file_error(
140    cwd: &std::path::Path,
141    path: &std::path::Path,
142    err: std::io::Error,
143) -> String {
144    if err.kind() == std::io::ErrorKind::NotFound {
145        format!(
146            "File does not exist. Current working directory: {}. Requested path: {}",
147            cwd.display(),
148            path.display()
149        )
150    } else {
151        err.to_string()
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use serde_json::json;
159    use std::collections::HashMap;
160
161    fn ctx(cwd: std::path::PathBuf, max_bytes: usize) -> ToolContext {
162        ToolContext {
163            session_id: "test".into(),
164            turn_id: 1,
165            tool_call_id: None,
166            cwd,
167            env: HashMap::new(),
168            messages: std::sync::Arc::new(vec![]),
169            progress: None,
170            read_file_state: std::sync::Arc::new(tokio::sync::Mutex::new(HashMap::new())),
171            timeout: None,
172            max_file_read_bytes: max_bytes,
173        }
174    }
175
176    #[tokio::test]
177    async fn rejects_oversized_file() {
178        let dir = std::env::temp_dir().join("tiny_agent_read_size_test");
179        let _ = std::fs::remove_dir_all(&dir);
180        std::fs::create_dir_all(&dir).unwrap();
181        std::fs::write(dir.join("big.txt"), "x".repeat(100)).unwrap();
182
183        let tool = FileReadTool;
184        let result = tool.invoke(json!({ "file_path": "big.txt" }), ctx(dir.clone(), 50)).await;
185        assert!(matches!(result, Err(AgentError::ToolExecution { .. })));
186        assert!(
187            result.unwrap_err().to_string().contains("maximum read size"),
188            "error should mention maximum read size"
189        );
190
191        let _ = std::fs::remove_dir_all(&dir);
192    }
193}