From 1ac35fbfd2d39a171285ff72b8ef089396828894 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Tue, 23 Dec 2025 09:50:23 -0700 Subject: [PATCH] agent: add byte-window read_file mode --- crates/agent/src/edit_agent/evals.rs | 26 ++ crates/agent/src/tools/edit_file_tool.rs | 10 + crates/agent/src/tools/read_file_tool.rs | 275 ++++++++++++++++++ .../remote_server/src/remote_editing_tests.rs | 4 + docs/src/SUMMARY.md | 4 + docs/src/ai/tools.md | 22 +- .../agent-tools/read-file-content-windows.md | 135 +++++++++ 7 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 docs/src/development/agent-tools/read-file-content-windows.md diff --git a/crates/agent/src/edit_agent/evals.rs b/crates/agent/src/edit_agent/evals.rs index 01c81e0103a2d3624c7e8eb9b9c587726fcc4876..bc376bf4968f87ec6b1687a1aa947ac4cc462b31 100644 --- a/crates/agent/src/edit_agent/evals.rs +++ b/crates/agent/src/edit_agent/evals.rs @@ -124,6 +124,8 @@ fn eval_extract_handle_command_output() { path: input_file_path.into(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }, )], ), @@ -185,6 +187,8 @@ fn eval_delete_run_git_blame() { path: input_file_path.into(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }, )], ), @@ -246,6 +250,8 @@ fn eval_translate_doc_comments() { path: input_file_path.into(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }, )], ), @@ -323,6 +329,8 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { path: input_file_path.into(), start_line: Some(971), end_line: Some(1050), + start_byte: None, + max_bytes: None, }, )], ), @@ -343,6 +351,8 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { path: input_file_path.into(), start_line: Some(1050), end_line: Some(1100), + start_byte: None, + max_bytes: None, }, )], ), @@ -363,6 +373,8 @@ fn eval_use_wasi_sdk_in_compile_parser_to_wasm() { path: input_file_path.into(), start_line: Some(1100), end_line: Some(1150), + start_byte: None, + max_bytes: None, }, )], ), @@ -521,6 +533,8 @@ fn eval_from_pixels_constructor() { path: input_file_path.into(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }, )], ), @@ -715,6 +729,8 @@ fn eval_zode() { path: "root/eval/react.py".into(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }, ), tool_use( @@ -724,6 +740,8 @@ fn eval_zode() { path: "root/eval/react_test.py".into(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }, ), ], @@ -826,6 +844,8 @@ fn eval_add_overwrite_test() { path: input_file_path.into(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }, )], ), @@ -925,6 +945,8 @@ fn eval_add_overwrite_test() { path: input_file_path.into(), start_line: Some(953), end_line: Some(1010), + start_byte: None, + max_bytes: None, }, ), ], @@ -950,6 +972,8 @@ fn eval_add_overwrite_test() { path: input_file_path.into(), start_line: Some(1012), end_line: Some(1120), + start_byte: None, + max_bytes: None, }, ), ], @@ -973,6 +997,8 @@ fn eval_add_overwrite_test() { path: input_file_path.into(), start_line: Some(271), end_line: Some(276), + start_byte: None, + max_bytes: None, }, ), ], diff --git a/crates/agent/src/tools/edit_file_tool.rs b/crates/agent/src/tools/edit_file_tool.rs index 3acb7f5951f3ca4b682dcabc62a0d54c35ab08d6..a60bf78840265a3d93aaa9afd4bbce46abdb8133 100644 --- a/crates/agent/src/tools/edit_file_tool.rs +++ b/crates/agent/src/tools/edit_file_tool.rs @@ -1850,6 +1850,8 @@ mod tests { path: "root/test.txt".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }, ToolCallEventStream::test().0, cx, @@ -1878,6 +1880,8 @@ mod tests { path: "root/test.txt".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }, ToolCallEventStream::test().0, cx, @@ -1949,6 +1953,8 @@ mod tests { path: "root/test.txt".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }, ToolCallEventStream::test().0, cx, @@ -2063,6 +2069,8 @@ mod tests { path: "root/test.txt".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }, ToolCallEventStream::test().0, cx, @@ -2174,6 +2182,8 @@ mod tests { path: "root/test.txt".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }, ToolCallEventStream::test().0, cx, diff --git a/crates/agent/src/tools/read_file_tool.rs b/crates/agent/src/tools/read_file_tool.rs index acfd4a16746fc1f78fd388f5dacf3e360f070ab5..3080b86d91a96a0b7d9c1adf389e63595b8721b7 100644 --- a/crates/agent/src/tools/read_file_tool.rs +++ b/crates/agent/src/tools/read_file_tool.rs @@ -10,16 +10,32 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; use std::sync::Arc; +use text::OffsetRangeExt as _; use util::markdown::MarkdownCodeBlock; use crate::{AgentTool, Thread, ToolCallEventStream, outline}; +const DEFAULT_MAX_BYTES: usize = 64 * 1024; +const HARD_MAX_BYTES: usize = 256 * 1024; +const MAX_SYNTAX_EXPANSION_ROWS: u32 = 500; + /// Reads the content of the given file in the project. /// /// - Never attempt to read a path that hasn't been previously mentioned. /// - For large files, this tool returns a file outline with symbol names and line numbers instead of the full content. /// This outline IS a successful response - use the line numbers to read specific sections with start_line/end_line. /// Do NOT retry reading the same file without line numbers if you receive an outline. +/// +/// This tool supports two ways of reading text: +/// +/// - **Line range mode**: provide `start_line` and/or `end_line` (1-based, inclusive end). +/// - **Byte window mode**: provide `start_byte` and/or `max_bytes` (0-based byte offsets). +/// Byte window results are rounded to whole line boundaries, prefer syntactic expansion when available, +/// and are bounded by a server-side hard cap. +/// +/// Byte window mode is intended for efficient paging and reducing repeated small reads. When used, +/// the returned content includes a brief header with the requested/rounded/returned byte ranges and line range, +/// which can be used to choose the next `start_byte` deterministically. #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct ReadFileToolInput { /// The relative path of the file to read. @@ -42,6 +58,26 @@ pub struct ReadFileToolInput { /// Optional line number to end reading on (1-based index, inclusive) #[serde(default)] pub end_line: Option, + + /// Optional byte offset to start reading on (0-based index). + /// + /// When provided (or when `max_bytes` is provided), this call uses **byte window mode**. + /// The returned content is rounded to whole line boundaries (no partial lines). + /// + /// For efficient paging, use the byte-range header included in byte window outputs to choose the next + /// `start_byte` deterministically. + #[serde(default)] + pub start_byte: Option, + + /// Optional maximum number of bytes to read. + /// + /// When provided (or when `start_byte` is provided), this call uses **byte window mode**. + /// The requested size is bounded by a server-side hard cap. + /// + /// Prefer setting a larger `max_bytes` (up to the hard cap) when you expect to read adjacent sections, to reduce + /// repeated paging calls. + #[serde(default)] + pub max_bytes: Option, } pub struct ReadFileTool { @@ -238,6 +274,132 @@ impl AgentTool for ReadFileTool { })?; Ok(result.into()) + } else if input.start_byte.is_some() || input.max_bytes.is_some() { + let (window_text, window_anchor) = buffer.read_with(cx, |buffer, _cx| { + let snapshot = buffer.snapshot(); + + let requested_start_offset = input + .start_byte + .unwrap_or(0) + .min(snapshot.len() as u64) as usize; + + let requested_len = input + .max_bytes + .map(|bytes| bytes as usize) + .unwrap_or(DEFAULT_MAX_BYTES); + + let requested_len = requested_len.min(HARD_MAX_BYTES); + + let requested_start_offset = + snapshot.as_rope().floor_char_boundary(requested_start_offset); + let requested_end_offset = snapshot + .as_rope() + .floor_char_boundary( + requested_start_offset + .saturating_add(requested_len) + .min(snapshot.len()), + ); + + let requested_byte_range = requested_start_offset..requested_end_offset; + let mut range = requested_byte_range.to_point(&snapshot); + + // Round to line boundaries: no partial lines. + range.start.column = 0; + range.end.column = snapshot.line_len(range.end.row); + + let rounded_byte_range = range.to_offset(&snapshot); + + // Prefer syntactic expansion (clean boundaries) when available, but only if it stays bounded. + let mut used_syntactic_expansion = false; + if let Some(ancestor_node) = snapshot.syntax_ancestor(range.clone()) { + let mut ancestor_range = ancestor_node.byte_range().to_point(&snapshot); + ancestor_range.start.column = 0; + + let max_end_row = (ancestor_range.start.row + MAX_SYNTAX_EXPANSION_ROWS) + .min(snapshot.max_point().row); + let capped_end_row = ancestor_range.end.row.min(max_end_row); + ancestor_range.end = + Point::new(capped_end_row, snapshot.line_len(capped_end_row)); + + let ancestor_byte_range = ancestor_range.to_offset(&snapshot); + if ancestor_byte_range.len() <= HARD_MAX_BYTES { + range = ancestor_range; + used_syntactic_expansion = true; + } + } + + let effective_byte_range = range.to_offset(&snapshot); + + let start_anchor = buffer.anchor_before(Point::new(range.start.row, 0)); + let end_row_exclusive = (range.end.row + 1).min(snapshot.max_point().row + 1); + let end_anchor = buffer.anchor_before(Point::new(end_row_exclusive, 0)); + let mut text = buffer.text_for_range(start_anchor..end_anchor).collect::(); + + let mut header = String::new(); + header.push_str("SUCCESS: Byte-window read.\n"); + header.push_str(&format!( + "Requested bytes: [{}-{}) (len {})\n", + requested_byte_range.start, + requested_byte_range.end, + requested_byte_range.len() + )); + header.push_str(&format!( + "Rounded bytes: [{}-{}) (len {})\n", + rounded_byte_range.start, + rounded_byte_range.end, + rounded_byte_range.len() + )); + header.push_str(&format!( + "Returned bytes: [{}-{}) (len {})\n", + effective_byte_range.start, + effective_byte_range.end, + effective_byte_range.len() + )); + header.push_str(&format!( + "Returned lines: {}-{}\n", + range.start.row + 1, + range.end.row + 1 + )); + header.push_str(&format!( + "Syntactic expansion: {}\n\n", + if used_syntactic_expansion { "yes" } else { "no" } + )); + + // Enforce a hard output cap. If the chosen range expanded beyond the cap (e.g. large lines), + // fall back to the rounded byte window and cap to HARD_MAX_BYTES on a UTF-8 boundary. + if effective_byte_range.len() > HARD_MAX_BYTES { + let fallback_end = snapshot.as_rope().floor_char_boundary( + (rounded_byte_range.start + HARD_MAX_BYTES).min(snapshot.len()), + ); + let fallback_range = + (rounded_byte_range.start..fallback_end).to_point(&snapshot); + + let fallback_start_anchor = + buffer.anchor_before(Point::new(fallback_range.start.row, 0)); + let fallback_end_anchor = + buffer.anchor_before(Point::new(fallback_range.end.row, 0)); + text = buffer + .text_for_range(fallback_start_anchor..fallback_end_anchor) + .collect::(); + + header.push_str( + "NOTE: Returned content exceeded the hard cap after rounding/expansion; \ +falling back to a capped byte window.\n\n", + ); + } + + (format!("{header}{text}"), Some(start_anchor)) + })?; + + if let Some(a) = window_anchor { + anchor = Some(a); + } + + action_log.update(cx, |log, cx| { + log.buffer_read(buffer.clone(), cx); + })?; + + Ok(window_text.into()) } else { // No line ranges specified, so check file size to see if it's too big. let buffer_content = outline::get_buffer_content_or_outline( @@ -339,6 +501,8 @@ mod test { path: "root/nonexistent_file.txt".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.run(input, event_stream, cx) }) @@ -383,6 +547,8 @@ mod test { path: "root/small_file.txt".into(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.run(input, ToolCallEventStream::test().0, cx) }) @@ -426,6 +592,8 @@ mod test { path: "root/large_file.rs".into(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -451,6 +619,8 @@ mod test { path: "root/large_file.rs".into(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.run(input, ToolCallEventStream::test().0, cx) }) @@ -511,6 +681,8 @@ mod test { path: "root/multiline.txt".to_string(), start_line: Some(2), end_line: Some(4), + start_byte: None, + max_bytes: None, }; tool.run(input, ToolCallEventStream::test().0, cx) }) @@ -518,6 +690,71 @@ mod test { assert_eq!(result.unwrap(), "Line 2\nLine 3\nLine 4\n".into()); } + #[gpui::test] + async fn test_read_file_with_byte_window_rounds_to_whole_lines(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/root"), + json!({ + "multiline.txt": "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" + }), + ) + .await; + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry, + Templates::new(), + Some(model), + cx, + ) + }); + let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); + + // Request a byte window that starts in the middle of "Line 2", which should round to whole lines. + let line_1 = "Line 1\n"; + let start_byte = (line_1.len() + 2) as u64; + + let result = cx + .update(|cx| { + let input = ReadFileToolInput { + path: "root/multiline.txt".to_string(), + start_line: None, + end_line: None, + start_byte: Some(start_byte), + max_bytes: Some(6), + }; + tool.run(input, ToolCallEventStream::test().0, cx) + }) + .await + .unwrap() + .to_str() + .unwrap() + .to_string(); + + assert!( + result.contains("Line 2\n"), + "Expected rounded output to include full line 2, got: {result:?}" + ); + assert!( + result.ends_with('\n'), + "Expected rounded output to end on a line boundary, got: {result:?}" + ); + assert!( + result.contains("\n\nLine 2\n"), + "Expected rounded output to include full line 2 after the byte-window header, got: {result:?}" + ); + } + #[gpui::test] async fn test_read_file_line_range_edge_cases(cx: &mut TestAppContext) { init_test(cx); @@ -554,6 +791,8 @@ mod test { path: "root/multiline.txt".to_string(), start_line: Some(0), end_line: Some(2), + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -567,6 +806,8 @@ mod test { path: "root/multiline.txt".to_string(), start_line: Some(1), end_line: Some(0), + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -580,6 +821,8 @@ mod test { path: "root/multiline.txt".to_string(), start_line: Some(3), end_line: Some(2), + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -668,6 +911,8 @@ mod test { path: "/outside_project/sensitive_file.txt".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -684,6 +929,8 @@ mod test { path: "project_root/allowed_file.txt".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -700,6 +947,8 @@ mod test { path: "project_root/.secretdir/config".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -715,6 +964,8 @@ mod test { path: "project_root/.mymetadata".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -731,6 +982,8 @@ mod test { path: "project_root/.mysecrets".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -746,6 +999,8 @@ mod test { path: "project_root/subdir/special.privatekey".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -761,6 +1016,8 @@ mod test { path: "project_root/subdir/data.mysensitive".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -777,6 +1034,8 @@ mod test { path: "project_root/subdir/normal_file.txt".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -791,6 +1050,8 @@ mod test { path: "project_root/../outside_project/sensitive_file.txt".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.run(input, ToolCallEventStream::test().0, cx) }) @@ -899,6 +1160,8 @@ mod test { path: "worktree1/src/main.rs".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -917,6 +1180,8 @@ mod test { path: "worktree1/src/secret.rs".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -938,6 +1203,8 @@ mod test { path: "worktree1/tests/fixture.sql".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -959,6 +1226,8 @@ mod test { path: "worktree2/lib/public.js".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -977,6 +1246,8 @@ mod test { path: "worktree2/lib/private.js".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -998,6 +1269,8 @@ mod test { path: "worktree2/docs/internal.md".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) @@ -1020,6 +1293,8 @@ mod test { path: "worktree1/src/config.toml".to_string(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; tool.clone().run(input, ToolCallEventStream::test().0, cx) }) diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index a7a870b0513694abe8b126fd0badea05534749ea..059bb23897a6d19c46c4094f042b2c132e2fc98a 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -1799,6 +1799,8 @@ async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mu path: "project/b.txt".into(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; let read_tool = Arc::new(ReadFileTool::new(thread.downgrade(), project, action_log)); let (event_stream, _) = ToolCallEventStream::test(); @@ -1811,6 +1813,8 @@ async fn test_remote_agent_fs_tool_calls(cx: &mut TestAppContext, server_cx: &mu path: "project/c.txt".into(), start_line: None, end_line: None, + start_byte: None, + max_bytes: None, }; let does_not_exist_result = cx.update(|cx| read_tool.run(input, event_stream, cx)); does_not_exist_result.await.unwrap_err(); diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index a82ddac990c4379df03db2b4bdcd8272eb8715e9..4616cd14174f15b0025e4d44f8060ff24d72cc8d 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -74,6 +74,10 @@ - [Plans and Usage](./ai/plans-and-usage.md) - [Billing](./ai/billing.md) +# Development + +- [Agent tools](./development/agent-tools/read-file-content-windows.md) + # Extensions - [Overview](./extensions.md) diff --git a/docs/src/ai/tools.md b/docs/src/ai/tools.md index e40cfcec840402ec881ecd29e9e3358a433c8d6f..713facee91a74108d68e1d08cd061e7c36f19ed1 100644 --- a/docs/src/ai/tools.md +++ b/docs/src/ai/tools.md @@ -6,7 +6,8 @@ Zed's built-in agent has access to a variety of tools that allow it to interact ### `diagnostics` -Gets errors and warnings for either a specific file or the entire project, useful after making edits to determine if further changes are needed. +Gets errors and warnings for either a specific file or the entire project. + When a path is provided, shows all diagnostics for that specific file. When no path is provided, shows a summary of error and warning counts for all files in the project. @@ -36,7 +37,24 @@ Opens a file or URL with the default application associated with it on the user' ### `read_file` -Reads the content of a specified file in the project, allowing access to file contents. +Reads the content of a specified file in the project. + +This tool supports two primary ways of reading text: + +- **Line range mode** (best when you already have line numbers, e.g. from an outline): + - Provide `start_line` and/or `end_line` (1-based, inclusive end). +- **Byte window mode** (best for paging efficiently and avoiding repeated small reads): + - Provide `start_byte` (0-based) and/or `max_bytes`. + - The returned content is **rounded to whole line boundaries** so it doesn’t start or end mid-line. + +For large files, calling `read_file` without a range may return a **file outline** with line numbers instead of full content. To read large files efficiently without line-number paging, use **byte window mode**. + +#### Byte window paging recommendations + +- Prefer setting a larger `max_bytes` when you expect to read multiple adjacent sections, to reduce tool calls and avoid rate limiting. +- Use `start_byte` to page forward/backward deterministically. + +When using byte window mode, the output includes a small header describing the effective returned window (requested/rounded/returned byte ranges and line ranges). Use the returned byte range to pick the next `start_byte` for continued paging. ### `thinking` diff --git a/docs/src/development/agent-tools/read-file-content-windows.md b/docs/src/development/agent-tools/read-file-content-windows.md new file mode 100644 index 0000000000000000000000000000000000000000..bc02119dbc46801ef40bc80bcc832da46e624389 --- /dev/null +++ b/docs/src/development/agent-tools/read-file-content-windows.md @@ -0,0 +1,135 @@ +# Project Brief: Byte-windowed `read_file` content windows (line-rounded) + +## Summary + +This project improves the Agent’s ability to read file contents efficiently by adding a byte-window mode to the `read_file` tool. The Agent will be able to request larger, deterministic content windows using byte offsets, and the tool will round returned output to whole line boundaries. The primary goal is to reduce repetitive paging calls (and associated latency/rate limits) while keeping the returned text easy to reason about and safe to splice into subsequent tool calls. + +This project is scoped to *paging mechanics* and does not attempt to build a global syntactic map of the codebase. + +## Background / Problem + +Today, when the Agent needs more context from a medium/large file, it often falls into a pattern like: + +- `read_file(path, lines 1-220)` +- `read_file(path, lines 220-520)` +- `read_file(path, lines 520-720)` +- …mixed with `grep` to find landmarks… + +This is expensive in: +- tool-call count (more opportunities to hit rate limits), +- latency (many round trips), +- model confusion (the model must pick line ranges and frequently underfetch/overfetch). + +A byte-window API provides: +- deterministic forward/back paging by `start_byte`, +- the ability to request a larger chunk up front (`max_bytes`), +- fewer total tool calls, especially on large files. + +## Goals + +1. **Enable large, deterministic reads**: Allow the Agent to request file content windows using byte offsets and sizes. +2. **Round to clean boundaries**: Returned content must be rounded to whole line boundaries (no partial lines). +3. **Reduce paging calls**: Encourage larger window sizes to reduce repeated reads. +4. **Maintain backwards compatibility**: Existing line-based reads (`start_line`, `end_line`) must continue to work unchanged. +5. **Be safe and bounded**: Enforce a server-side cap to prevent extremely large tool outputs. + +## Non-goals + +- Providing symbol/outline querying or syntactic navigation of large files. +- Building a global codebase index or “syntactic map”. +- Altering privacy/exclusion behavior for file access. +- Changing how outlines are generated for large files, except where required to support byte windows cleanly. + +## Proposed Tool Input Changes + +Extend the `read_file` tool input schema with optional byte-window parameters: + +- `start_byte: Option` + - 0-based byte offset into the file. + - When omitted, defaults to `0` in byte-window mode. + +- `max_bytes: Option` + - Requested maximum bytes for the window. + - When omitted, a default will be applied (see Defaults & Caps). + +### Precedence Rules + +1. If `start_line` or `end_line` is provided, treat the call as line-range mode (existing behavior). +2. Otherwise, if `start_byte` or `max_bytes` is provided, treat the call as byte-window mode. +3. Otherwise, preserve current behavior (small file full content, large file outline fallback). + +## Byte-window Mode Semantics + +### Window selection + +Given: +- `start = start_byte.unwrap_or(0)` +- `requested_len = max_bytes.unwrap_or(DEFAULT_MAX_BYTES)` +- `end = start + requested_len` + +The implementation will clamp to file length and adjust to safe UTF-8 boundaries before converting to internal points/ranges. + +### Line rounding + +The returned content must be rounded to line boundaries: + +- Start is snapped to the beginning of the line containing `start`. +- End is snapped so the output includes only whole lines (typically to the beginning of the next line after `end`, or to end-of-file). + +The key property: **no partial lines** are returned. + +### Deterministic paging + +The Agent should be able to page by setting `start_byte` to the prior returned window end (or a tracked byte offset) and requesting another window. + +To support this, the tool may optionally include the effective returned byte range in the output (as a short header) so the Agent can page precisely. + +## Defaults & Caps + +### Defaults + +- `DEFAULT_MAX_BYTES`: A conservative default that is large enough to reduce paging (e.g., 64KiB), but small enough to keep tool outputs manageable. + +### Hard cap + +- `HARD_MAX_BYTES`: A strict server-side maximum (e.g., 256KiB) applied even if a larger `max_bytes` is requested. + +If the Agent requests more than `HARD_MAX_BYTES`, the tool will clamp the request to `HARD_MAX_BYTES`. + +## Edge Cases / Special Handling + +- **Single extremely long line**: If a single line is longer than `HARD_MAX_BYTES`, rounding to full lines can cause a window to exceed the cap. The implementation must define behavior for this case. Recommended approach: + - Prefer whole-line output; however, if a single line exceeds the hard cap, allow returning that line truncated with an explicit note, or fall back to returning a bounded slice with clear markers. The chosen behavior must remain deterministic and avoid invalid UTF-8. + +- **Files larger than the “auto-outline” threshold**: Byte-window mode should allow reading chunks even for large files (where the default no-args call would return an outline). This enables efficient paging without requiring the Agent to switch to line ranges. + +- **UTF-8 safety**: Byte offsets must not split multi-byte characters. + +## UX Guidance for the Agent + +The tool documentation should recommend: + +- Use `max_bytes` generously (up to the hard cap) when you expect to need more context, to reduce paging and rate-limit pressure. +- Prefer byte-window paging for “continue reading” workflows; use line ranges when following up on outline-provided line numbers. + +## Telemetry / Observability (optional) + +If available, log: +- number of `read_file` calls per task, +- average output size, +- frequency of paging patterns (repeated reads of same file), +- rate-limit related failures. + +This will validate that the change reduces tool-call volume in practice. + +## Acceptance Criteria + +- `read_file` accepts `start_byte` and `max_bytes` and returns content rounded to whole lines. +- Output is safe UTF-8 and does not panic on boundary conditions. +- A single call can return a substantially larger chunk than typical line-based paging patterns, reducing follow-up reads. +- Existing `start_line` / `end_line` behavior remains unchanged. +- Large file default behavior (outline) remains unchanged when byte-window parameters are not provided. + +## Out of Scope Follow-up + +A separate project will address “global syntactic navigation” (e.g., outline querying, codebase-wide symbol maps, and syntactic retrieval interfaces). This brief intentionally does not cover that work. \ No newline at end of file