//! `Read` tool — read a text file with offset/limit, return line-numbered output. use std::sync::Arc; use async_trait::async_trait; use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use serde::Deserialize; use crate::error::ToolsError; use crate::tracker::Tracker; use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle}; const DESCRIPTION: &str = "Read a text file from the local filesystem. \ Supports offset/limit for large files. Returns line-numbered output (1-based). \ Directories cannot be read. The file must be read before Write or Edit can \ modify it. Paths are relative to the bound Workdir."; const DEFAULT_LIMIT: usize = 2000; const PROVIDER_BYTE_LIMIT: usize = 256 * 1024; #[derive(Debug, Deserialize, schemars::JsonSchema)] pub(crate) struct ReadParams { /// Logical path relative to the bound Workdir root. pub file_path: String, /// 0-based line offset from the start. Defaults to 0. #[serde(default)] pub offset: Option, /// Maximum number of lines to return. Defaults to 2000. #[serde(default)] pub limit: Option, } pub(crate) struct ReadTool { session: WorkdirSessionHandle, tracker: Tracker, } #[async_trait] impl Tool for ReadTool { async fn execute( &self, input_json: &str, _ctx: llm_engine::tool::ToolExecutionContext, ) -> Result { let params: ReadParams = serde_json::from_str(input_json) .map_err(|e| ToolError::InvalidArgument(format!("invalid Read input: {e}")))?; let offset = params.offset.unwrap_or(0); let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1); let path = WorkdirPath::new(¶ms.file_path).map_err(ToolsError::from)?; tracing::debug!(path = %path, offset, limit, "Read"); let result = self .session .read(ReadRequest { path: path.clone(), offset, limit, max_bytes: PROVIDER_BYTE_LIMIT, }) .await .map_err(ToolsError::from)?; self.tracker.record_workdir_hash(&path, result.content_hash); let text = String::from_utf8_lossy(&result.bytes).into_owned(); let rendered = render_provider_read( &text, result.start_line, result.total_lines, result.truncated, ); let summary = if rendered.truncated { format!( "Read {} line(s) [{}..{}] of {} from {}", rendered.line_count, offset + 1, offset + rendered.line_count, rendered.total_lines, path ) } else { format!("Read {} line(s) from {}", rendered.line_count, path) }; Ok(ToolOutput { summary, content: Some(rendered.body), attachments: Vec::new(), }) } } struct Rendered { body: String, line_count: usize, total_lines: usize, truncated: bool, } fn render_provider_read( text: &str, start_line: usize, total_lines: usize, truncated: bool, ) -> Rendered { use std::fmt::Write as _; let lines = text.lines().collect::>(); let mut body = String::with_capacity(text.len().saturating_add(lines.len() * 8)); for (index, line) in lines.iter().enumerate() { let _ = writeln!(&mut body, "{:>6}\t{}", start_line + index + 1, line); } Rendered { body, line_count: lines.len(), total_lines, truncated: start_line > 0 || truncated, } } /// Format a slice of lines from `text` with `cat -n` style 1-based line /// numbers. Pure function — no I/O, no history touching. #[cfg(test)] fn render_numbered(text: &str, offset: usize, limit: usize) -> Rendered { let all_lines: Vec<&str> = text.lines().collect(); let total_lines = all_lines.len(); let start = offset.min(total_lines); let end = start.saturating_add(limit).min(total_lines); let slice = &all_lines[start..end]; let line_count = slice.len(); use std::fmt::Write as _; let mut body = String::with_capacity(text.len().saturating_add(line_count * 8)); for (i, line) in slice.iter().enumerate() { let lineno = start + i + 1; let _ = writeln!(&mut body, "{:>6}\t{}", lineno, line); } Rendered { body, line_count, total_lines, truncated: start > 0 || end < total_lines, } } /// Factory for the `Read` tool. pub fn read_tool(session: WorkdirSessionHandle, tracker: Tracker) -> ToolDefinition { Arc::new(move || { let schema = schemars::schema_for!(ReadParams); let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({})); let meta = ToolMeta::new("Read") .description(DESCRIPTION) .input_schema(schema_value); let tool: Arc = Arc::new(ReadTool { session: session.clone(), tracker: tracker.clone(), }); (meta, tool) }) } #[cfg(test)] mod tests { use super::*; use manifest::Scope; use tempfile::TempDir; use workdir::LocalWorkdirSession; fn setup() -> (TempDir, WorkdirSessionHandle, Tracker) { let dir = TempDir::new().unwrap(); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new( Scope::writable(dir.path()).unwrap(), dir.path().to_path_buf(), )); (dir, session, Tracker::new()) } #[tokio::test] async fn read_tool_basic_records_history() { let (dir, fs, tracker) = setup(); let file = dir.path().join("a.txt"); std::fs::write(&file, "alpha\nbeta\ngamma\n").unwrap(); let def = read_tool(fs, tracker.clone()); let (meta, tool) = def(); assert_eq!(meta.name, "Read"); let input = serde_json::json!({ "file_path": file.file_name().unwrap().to_str().unwrap() }); let out = tool .execute(&input.to_string(), Default::default()) .await .unwrap(); assert!(out.summary.contains("Read 3 line(s)")); let body = out.content.unwrap(); assert!(body.contains(" 1\talpha")); assert!(body.contains(" 3\tgamma")); // History recorded assert!( tracker .expected_workdir_hash(&WorkdirPath::new("a.txt").unwrap()) .is_ok() ); } #[tokio::test] async fn read_tool_offset_limit() { let (dir, fs, tracker) = setup(); let file = dir.path().join("a.txt"); std::fs::write(&file, "1\n2\n3\n4\n5\n").unwrap(); let def = read_tool(fs, tracker); let (_, tool) = def(); let input = serde_json::json!({ "file_path": file.file_name().unwrap().to_str().unwrap(), "offset": 1, "limit": 2, }); let out = tool .execute(&input.to_string(), Default::default()) .await .unwrap(); assert!(out.summary.contains("[2..3] of 5")); let body = out.content.unwrap(); assert!(body.contains(" 2\t2")); assert!(body.contains(" 3\t3")); } #[tokio::test] async fn read_tool_missing_file() { let (dir, fs, tracker) = setup(); let def = read_tool(fs, tracker); let (_, tool) = def(); let input = serde_json::json!({ "file_path": "nope.txt" }); let err = tool .execute(&input.to_string(), Default::default()) .await .unwrap_err(); assert!(matches!(err, ToolError::ExecutionFailed(_))); } #[tokio::test] async fn read_tool_bad_json() { let (_dir, fs, tracker) = setup(); let def = read_tool(fs, tracker); let (_, tool) = def(); let err = tool .execute("not json", Default::default()) .await .unwrap_err(); assert!(matches!(err, ToolError::InvalidArgument(_))); } }