Skip to content

Commit 93c80e5

Browse files
yogthosYogthos
andauthored
fix(F4): stream-read large files instead of 10MB hard cap (#79)
Track F-HIGH #4 from ROADMAP.md. ## Problem `read.rs:91-98` rejected files >10MB outright with `File too large (N bytes). Max 10MB.` Agents couldn't sample large logs, generated outputs, build artifacts, or test fixtures. Workaround was a bash `head`/`tail` invocation, which obscures intent and skips the LSP warmup that read provides. ## Fix Replace eager `tokio::fs::read_to_string` with a streaming `tokio::io::BufReader::lines()`: - Stream line-by-line, tracking total count for the header. - Truncate any individual line longer than `MAX_LINE_BYTES = 16384` to defend against pathological minified-JS / accidental binary reads. UTF-8 boundary-safe truncation; trailing ` …[line truncated]` marker so the LLM sees the cut. - Keep an excerpt buffer of just `[offset, offset+limit)` lines — doesn't grow with file size. - New safety net: `MAX_FILE_BYTES = 1GB`. Beyond that we still refuse but the error suggests bash + head/tail/grep instead. Matches opencode's `read.ts:119-150` stream + early-terminate shape and pi's `read.ts:215-328` smart truncation. ## Tests Two new tests in `agent::tools::read::tests`: - `read_truncates_pathological_long_lines`: writes a file with a 100KB single line plus normal lines; asserts the long line is truncated with the marker and total output is <100KB. - `read_handles_files_larger_than_old_10mb_cap`: 1MB fixture (10k × 99-byte lines); asserts read succeeds, header shows the true total line count, and the excerpt is just the requested 5 lines. 656 → 658 pass. All build profiles clean. Co-authored-by: Yogthos <yogthos@gmail.com>
1 parent f52512a commit 93c80e5

1 file changed

Lines changed: 127 additions & 11 deletions

File tree

src/agent/tools/read.rs

Lines changed: 127 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,28 +87,66 @@ impl Tool for ReadTool {
8787
}
8888
}
8989

90+
// F4: stream the file line-by-line via BufReader instead of
91+
// loading the whole thing into memory with `read_to_string`.
92+
// Removes the prior 10MB hard cap (large logs / generated
93+
// files were unreachable) and caps each individual line at
94+
// `MAX_LINE_BYTES` so a pathological minified-JS file with a
95+
// 100MB single line doesn't OOM us. opencode (`read.ts:119-150`)
96+
// and pi (`read.ts:215-328`) both stream + smart-truncate.
97+
//
98+
// Safety net: refuse files larger than 1GB. Reading 1GB into
99+
// even line-counting takes a while; if a user needs this we'd
100+
// tell them to use bash + head/tail/grep.
101+
const MAX_FILE_BYTES: u64 = 1024 * 1024 * 1024;
102+
const MAX_LINE_BYTES: usize = 16 * 1024;
103+
const TRUNC_MARKER: &str = " …[line truncated]";
104+
90105
let metadata = tokio::fs::metadata(&args.path).await?;
91106
let file_size = metadata.len();
92-
if file_size > 10 * 1024 * 1024 {
107+
if file_size > MAX_FILE_BYTES {
93108
return Err(ToolError::Msg(format!(
94-
"File too large ({} bytes). Max 10MB.",
109+
"File too large ({} bytes). Max 1GB. Use bash + head/tail/grep for sampling.",
95110
file_size
96111
)));
97112
}
98-
let content = tokio::fs::read_to_string(&args.path).await?;
99-
let total_lines = content.lines().count();
100113

101114
let offset = args.offset.unwrap_or(1).max(1) - 1;
102115
let limit = args.limit.unwrap_or(2000);
103-
let end = (offset + limit).min(total_lines);
104116

117+
use tokio::io::AsyncBufReadExt;
118+
let file = tokio::fs::File::open(&args.path).await?;
119+
let reader = tokio::io::BufReader::new(file);
120+
let mut lines = reader.lines();
121+
let mut total_lines = 0usize;
122+
let mut excerpt_lines: Vec<(usize, String)> = Vec::with_capacity(limit);
123+
let want_end = offset.saturating_add(limit);
124+
while let Some(line) = lines.next_line().await.transpose() {
125+
let mut line = line?;
126+
if line.len() > MAX_LINE_BYTES {
127+
// Truncate by byte index — careful to land on a UTF-8
128+
// boundary. Drop bytes until we find one.
129+
let mut truncate_at = MAX_LINE_BYTES;
130+
while !line.is_char_boundary(truncate_at) {
131+
truncate_at -= 1;
132+
}
133+
line.truncate(truncate_at);
134+
line.push_str(TRUNC_MARKER);
135+
}
136+
let line_idx = total_lines;
137+
total_lines += 1;
138+
if line_idx >= offset && line_idx < want_end {
139+
excerpt_lines.push((line_idx, line));
140+
}
141+
// Past the requested range — keep counting to compute
142+
// `total_lines` for the header, but skip allocation.
143+
}
144+
145+
let end = (offset + limit).min(total_lines);
105146
let width = (total_lines.to_string().len()).max(1);
106-
let excerpt: String = content
107-
.lines()
108-
.skip(offset)
109-
.take(end - offset)
110-
.enumerate()
111-
.map(|(i, line)| format!("{:>width$}: {}", offset + i + 1, line))
147+
let excerpt: String = excerpt_lines
148+
.into_iter()
149+
.map(|(idx, line)| format!("{:>width$}: {}", idx + 1, line))
112150
.collect::<Vec<_>>()
113151
.join("\n");
114152
let info = format!(
@@ -141,6 +179,9 @@ impl Tool for ReadTool {
141179

142180
#[cfg(test)]
143181
mod tests {
182+
use super::*;
183+
use crate::agent::tools::ReadArgs;
184+
144185
/// Verifies the line-numbering format used in read output.
145186
/// The model sees this format and must strip "NNN: " prefixes when passing text to edit.
146187
#[test]
@@ -160,4 +201,79 @@ mod tests {
160201

161202
assert_eq!(excerpt, "1: line one\n2: line two\n3: line three");
162203
}
204+
205+
fn temp_path(suffix: &str) -> std::path::PathBuf {
206+
std::env::temp_dir().join(format!("dirge-read-test-{}-{}", std::process::id(), suffix,))
207+
}
208+
209+
/// F4: pathological lines (e.g. 100KB single line from minified JS
210+
/// or accidentally cat'd binary) truncate at MAX_LINE_BYTES with a
211+
/// clear marker. Without this, the LLM context could be flooded
212+
/// with a single multi-MB line.
213+
#[tokio::test]
214+
async fn read_truncates_pathological_long_lines() {
215+
let path = temp_path("longline");
216+
let pathological = "a".repeat(100_000);
217+
std::fs::write(&path, format!("short\n{}\nshort2", pathological)).unwrap();
218+
219+
let tool = ReadTool::new(None, None);
220+
let out = tool
221+
.call(ReadArgs {
222+
path: path.to_string_lossy().into_owned(),
223+
offset: None,
224+
limit: None,
225+
})
226+
.await
227+
.unwrap();
228+
let _ = std::fs::remove_file(&path);
229+
230+
assert!(
231+
out.contains("…[line truncated]"),
232+
"truncation marker missing"
233+
);
234+
assert!(
235+
out.len() < 100_000,
236+
"output should not contain the full 100k line; got {} bytes",
237+
out.len(),
238+
);
239+
assert!(out.contains("short"), "first short line missing");
240+
assert!(out.contains("short2"), "trailing short line missing");
241+
}
242+
243+
/// F4: files >10MB used to be rejected outright. Stream-read
244+
/// should handle them up to the new 1GB safety net. Skip the
245+
/// expensive case in CI but at least verify a 1MB file works.
246+
#[tokio::test]
247+
async fn read_handles_files_larger_than_old_10mb_cap() {
248+
let path = temp_path("mediumfile");
249+
// 1MB of repeated 100-byte lines = ~10_000 lines.
250+
let line = "x".repeat(99);
251+
let body = (0..10_000)
252+
.map(|_| line.as_str())
253+
.collect::<Vec<_>>()
254+
.join("\n");
255+
std::fs::write(&path, &body).unwrap();
256+
assert!(body.len() > 900_000, "fixture is at least ~1MB");
257+
258+
let tool = ReadTool::new(None, None);
259+
let result = tool
260+
.call(ReadArgs {
261+
path: path.to_string_lossy().into_owned(),
262+
offset: Some(1),
263+
limit: Some(5),
264+
})
265+
.await;
266+
let _ = std::fs::remove_file(&path);
267+
268+
let out = result.expect("read of medium file must succeed");
269+
// Header reports total_lines as the real count, not capped.
270+
assert!(
271+
out.contains("10000 lines total") || out.contains("10001 lines total"),
272+
"expected ~10000 line total in header; got: {}",
273+
out.lines().next().unwrap_or(""),
274+
);
275+
// Only the first 5 are in the excerpt.
276+
let body_lines: Vec<&str> = out.lines().skip(2).collect();
277+
assert_eq!(body_lines.len(), 5);
278+
}
163279
}

0 commit comments

Comments
 (0)