Skip to content

File Read

The file read tool gives the model structured access to file contents. Rather than injecting entire files into the system prompt at session start, the model requests files on demand as tool calls, receiving back the contents with line numbers attached. This enables the model to read only what it needs, pay context cost proportionally, and read files that weren’t known at session start.

The challenge is not reading files — that is trivial. The challenge is presenting file contents in a form the model can reason about efficiently: line-numbered so edits can reference specific lines, truncated at predictable boundaries so the model does not unexpectedly consume its whole context window on a large file, and extended to handle non-text formats (images, PDFs, binary data) without crashing.

Repo mapping (documented at Repo Mapping) injects a compact structural summary of the entire codebase: function names, class definitions, call graph. The read tool gives verbatim line content when the model needs to see the actual code. The two are complementary: repo map for orientation, read tool for precision.



Aider does not expose a read_file tool to the model. File contents enter the conversation through two other mechanisms.

The /add chat command adds a file to the “chat files” set. Its full contents are injected into the system message at the start of every subsequent turn. The model sees the file on every call, not on demand.

The repo map injects extracted symbol tags (function names, class names, referenced identifiers) from files not explicitly added. The model sees a structural summary, not the file contents.

Neither mechanism is a tool call in the JSON tool-use sense. The model cannot request a file it did not already have in context — it must ask the user to /add it.

Consequences:

  • The model cannot discover file contents autonomously without the user’s help
  • Added files stay in context for the entire session, consuming tokens even when no longer relevant
  • There is no line-range or offset mechanism; the whole file is always included

Codex exposes read_file defined in codex-rs/core/src/tools/spec.rs with the implementation in codex-rs/core/src/tools/handlers/read_file.rs (commit 4ab44e2c5).

struct ReadFileArgs {
/// Absolute path to the file.
file_path: String,
/// 1-indexed line number to start reading from. Defaults to 1.
offset: usize,
/// Maximum number of lines to read. Defaults to 2000.
limit: usize,
/// Reading mode: "slice" (simple range) or "indentation" (smart block).
mode: ReadMode,
/// Only used when mode = "indentation".
indentation: Option<IndentationArgs>,
}
enum ReadMode {
Slice, // returns lines [offset, offset+limit)
Indentation, // smart block expansion around an anchor line
}
struct IndentationArgs {
/// Center point for block expansion. Defaults to offset.
anchor_line: Option<usize>,
/// How many parent indentation levels to include (0 = unlimited).
max_levels: usize,
/// Include sibling blocks at the same indentation depth.
include_siblings: bool,
/// Include comments/docstrings immediately above the anchor.
include_header: bool,
/// Hard line cap regardless of indentation expansion.
max_lines: Option<usize>,
}

The simplest mode. Reads lines [offset, offset + limit) from the file.

Output (read_file.rs:156–221):

L1: fn main() {
L2: let x = 42;
L3: println!("{}", x);
L4: }

Every line is prefixed with L<number>: . Line numbers are 1-indexed and match the actual file line numbers, not the output position. If offset exceeds the file’s total line count, the tool returns an error rather than an empty result.

Constants:

  • MAX_LINE_LENGTH = 500 characters; lines beyond this are truncated with
  • No total byte cap at this layer; the caller’s context budget handles it

Codex’s distinctive feature. Instead of a fixed line range, the model specifies an anchor line and the tool expands outward to include the full indentation block that contains it (read_file.rs:223–430).

Algorithm:

  1. Read the entire file into memory once
  2. Compute the effective indentation depth of each line (treating blank lines as transparent)
  3. Find the anchor line (defaults to offset if anchor_line is not given)
  4. Determine the anchor’s effective depth
  5. Walk upward from the anchor, collecting lines at depth ≥ anchor depth
  6. Walk downward from the anchor, collecting lines at depth ≥ anchor depth
  7. If max_levels > 0: also include up to max_levels parent levels (lines at shallower depth above the anchor)
  8. If include_siblings = false: stop at the first gap in indentation, excluding blocks that share the same depth but start after a blank line
  9. If include_header = true: include comment lines immediately above the anchor (detects //, #, -- prefixes via COMMENT_PREFIXES)
  10. Apply max_lines hard cap if given
  11. Trim leading and trailing blank lines from the result

Tab expansion: Tabs are counted as TAB_WIDTH = 4 spaces for indentation purposes.

Example use case: The model knows a function is at line 200 but doesn’t know where it ends. With mode: indentation, anchor_line: 200, max_levels: 1, include_header: true, it gets the function’s docstring, signature, and complete body without knowing the end line number.

Both modes return lines in the format:

L<number>: <content>

Joined with newlines. No wrapping XML or structured envelope — just the formatted text, injected directly as a tool result.


OpenCode exposes a read tool in packages/opencode/src/tool/read.ts (commit 7ed44997).

parameters: z.object({
filePath: z.string()
.describe("The absolute path to the file or directory to read"),
offset: z.coerce.number()
.describe("The line number to start reading from (1-indexed)")
.optional(),
limit: z.coerce.number()
.describe("The maximum number of lines to read (defaults to 2000)")
.optional(),
})

Note the z.coerce.number() — the model often emits offset/limit as strings, and Zod’s coerce converts them silently rather than failing schema validation.

read.ts:110–139 dispatches based on content type before attempting to read as text:

Images (except SVG):

if (mime.startsWith("image/") && mime !== "image/svg+xml") {
const data = await fs.readFile(filepath);
return {
type: "image",
mimeType: mime,
data: data.toString("base64"),
};
}

Returned as a base64 attachment alongside the text content block, enabling multimodal models to see the actual image.

PDFs: Same pattern — base64-encoded, with application/pdf mime type.

Binary file detection (read.ts:148–158):

// A file is binary if:
// 1. It contains a null byte (\x00), OR
// 2. More than 30% of its first 512 bytes are non-printable (< 0x20 and not whitespace)
function isBinary(buf: Buffer): boolean {
if (buf.includes(0x00)) return true;
const nonPrintable = buf.slice(0, 512).filter(b => b < 0x20 && b !== 9 && b !== 10 && b !== 13).length;
return nonPrintable / Math.min(buf.length, 512) > 0.3;
}

Binary files return an error message telling the model the file is binary and suggesting alternatives.

SVG and other text types: Fall through to the line-based reader.

If the path points to a directory, read.ts:68–107 enumerates entries:

const entries = await fs.readdir(filepath, { withFileTypes: true });
const formatted = entries
.sort((a, b) => a.name.localeCompare(b.name))
.map(e => e.isDirectory() ? e.name + "/" : e.name);
// Apply offset/limit (1-indexed)
const page = formatted.slice((offset - 1) || 0, (offset - 1 || 0) + (limit || formatted.length));

Output:

<path>/home/user/project/src</path>
<type>directory</type>
<entries>
index.ts
lib/
README.md
tests/
</entries>

read.ts:140–180 for text files:

const DEFAULT_READ_LIMIT = 2000; // lines
const MAX_LINE_LENGTH = 2000; // chars per line
const MAX_BYTES = 50 * 1024; // 50 KB total byte cap
const lines = content.split("\n");
const pageLines = lines.slice((offset - 1) || 0, ((offset - 1) || 0) + (limit || DEFAULT_READ_LIMIT));
let byteCount = 0;
const result = pageLines.map((line, i) => {
let l = line;
if (l.length > MAX_LINE_LENGTH) {
l = l.slice(0, MAX_LINE_LENGTH) + "";
}
byteCount += Buffer.byteLength(l, "utf8");
if (byteCount > MAX_BYTES) return null; // stop adding lines
return `L${(offset - 1 || 0) + i + 1}: ${l}`;
}).filter(Boolean).join("\n");
<path>/absolute/path/to/file.ts</path>
<type>file</type>
<content>
L1: import { foo } from "./foo";
L2:
L3: export function bar() {
L4: return foo();
L5: }
</content>
<summary>
Total lines: 42 | Showing: lines 1–5 | Truncated: false
</summary>

XML tags provide a structured envelope. The summary line tells the model how many total lines the file has and whether it received the full view or a truncated slice.

After reading, read.ts may append any InstructionPrompt hints associated with the file path (from the instruction resolution system). These are additional instructions embedded in AGENTS.md or similar files relevant to the file’s directory. Injected as a separate block after the file content.

// assert the path is inside Instance.directory or a configured allowlist
await assertExternalDirectory(ctx, filepath, { allowRead: true });
// request read permission (almost always auto-approved; "always: ['*']" pattern)
await ctx.ask({
permission: "read",
patterns: [filepath],
always: ["*"], // auto-approve all reads once permission granted once
metadata: {},
});

In practice, reads are almost never blocked — the always: ["*"] wildcard means the first approval auto-approves all future reads. But the hook exists so the plugin system can intercept reads of specific sensitive files.


If the tool outputs L42: function foo(), the model may later emit an edit targeting line 42. If the tool’s line counting differs from the file’s actual line count (e.g., due to Windows \r\n vs Unix \n), the edit will target the wrong line. Both Codex and OpenCode split on \n only — never on \r\n — and strip trailing \r per line if present. OpenOxide must match this behavior.

A 2000-line limit handles most source files, but generated files, lockfiles (package-lock.json, Cargo.lock), and minified JS can be 10,000+ lines. When a model reads one of these files and hits the truncation, it sees a partial picture and may make wrong assumptions. The tool should include a clear summary indicating truncation so the model knows to use grep or glob instead.

The 30% non-printable heuristic flags UTF-16 encoded files as binary because they contain \x00 bytes. A UTF-16 encoded README would appear binary even though it is perfectly valid text. Better detection reads the BOM (\xff\xfe for UTF-16 LE, \xfe\xff for UTF-16 BE) and re-decodes accordingly rather than treating non-ASCII as binary.

Listing a directory with tens of thousands of entries (e.g., node_modules/) returns all entries before applying offset/limit. The readdir() call blocks until all entries are collected, which can take seconds for very large directories. Apply a pre-emptive cap (MAX_ENTRIES = 5000) and return a truncation message rather than listing all entries.

Returning a base64-encoded image as an attachment costs tokens at the vision encoding rate, not the text token rate. A 1 MB PNG might cost more tokens than reading 10,000 lines of code. The tool should warn the model if an image exceeds a size threshold (e.g., 200 KB) and offer to describe it via the model’s vision capability instead of returning the raw bytes.

Both Codex and OpenCode use 1-indexed offsets (line 1 is the first line). This is consistent with how editors display line numbers. If OpenOxide uses 0-indexed offsets, the model will systematically request one line off from what it intends.

Codex caps lines at 500 characters. Minified CSS, long SQL strings, and some generated code have lines well over 500 characters. A truncated line at column 500 may not be valid syntax, and the model may try to edit it without realizing it saw an incomplete line. OpenCode’s 2000-character limit is more practical.


#[derive(Deserialize, JsonSchema)]
pub struct ReadFileParams {
/// Absolute path to the file or directory.
pub file_path: PathBuf,
/// 1-indexed starting line. Defaults to 1.
pub offset: Option<usize>,
/// Maximum lines to return. Defaults to 2000.
pub limit: Option<usize>,
/// Reading mode. Defaults to "slice".
pub mode: Option<ReadMode>,
/// Options for indentation mode.
pub indentation: Option<IndentationOptions>,
}
#[derive(Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReadMode {
Slice,
Indentation,
}
pub const DEFAULT_READ_LIMIT: usize = 2000;
pub const MAX_LINE_LENGTH: usize = 2000; // match OpenCode, not Codex's 500
pub const MAX_BYTES: usize = 50 * 1024;
pub const MAX_DIR_ENTRIES: usize = 5000;
pub async fn execute(params: ReadFileParams, ctx: &ToolContext) -> ToolResult {
let path = ctx.resolve_path(&params.file_path)?;
if path.is_dir() {
return read_directory(&path, params.offset, params.limit).await;
}
let mime = mime_guess::from_path(&path).first_or_octet_stream();
if is_image_mime(&mime) {
return read_as_image(&path, &mime).await;
}
if mime.essence_str() == "application/pdf" {
return read_as_pdf(&path).await;
}
let bytes = tokio::fs::read(&path).await?;
if is_binary(&bytes) {
return Err(ToolError::BinaryFile(path));
}
match params.mode.unwrap_or(ReadMode::Slice) {
ReadMode::Slice => read_slice(&bytes, params.offset, params.limit),
ReadMode::Indentation => read_indentation(&bytes, params.indentation),
}
}

Implement Codex’s algorithm verbatim. It is genuinely useful and no other implementation has it. Key differences from Codex to fix:

  • Use MAX_LINE_LENGTH = 2000 not 500
  • Return a summary line showing how many lines were in the block vs the file total
  • Expose max_lines as a required field (not optional) with a clear default

Match OpenCode’s XML envelope:

<path>{absolute_path}</path>
<type>file</type>
<content>
L{n}: {line}
...
</content>
<summary>Total: {total} lines | Showing: {start}–{end} | Truncated: {bool}</summary>

For directories:

<path>{absolute_path}</path>
<type>directory</type>
<entries>
{name}
{name}/
...
</entries>
<summary>Total: {n} entries | Showing: {start}–{end}</summary>
[dependencies]
tokio = { features = ["fs"] }
mime_guess = "2"
base64 = "0.22"
serde = { features = ["derive"] }
schemars = "0.8"