feat: add line-based read_file tool with safety limits (#22400)

## Summary

Adds a new line-based file reading endpoint to the workspace agent,
replacing the unbounded byte-based approach for the `read_file` chat
tool and `coder_workspace_read_file` MCP tool.

**Problem**: The current `read_file` tool returns the entire file
contents with no limits, which can blow up LLM context windows and cause
OOM issues with large files.

**Solution**: Inspired by [`coder/mux`](https://github.com/coder/mux)
and [`openai/codex`](https://github.com/openai/codex), implement a
line-based reader with safety limits.

## Changes

### Agent (`agent/agentfiles/`)
- New `/read-file-lines` endpoint with `HandleReadFileLines` handler
- Line-based `offset` (1-based line number, default: 1) and `limit`
(line count, default: 2000)
- Safety constants:
  | Constant | Value | Purpose |
  |---|---|---|
  | `MaxFileSize` | 1 MB | Reject files larger than this at stat |
| `MaxLineBytes` | 1,024 | Per-line truncation with `... [truncated]`
marker |
  | `MaxResponseLines` | 2,000 | Max lines per response |
  | `MaxResponseBytes` | 32 KB | Max total response size |
  | `DefaultLineLimit` | 2,000 | Default when no limit specified |
- Line numbering format: `1\tcontent` (tab-separated)
- Structured JSON response: `{ success, file_size, total_lines,
lines_read, content, error }`
- Hard errors when limits exceeded — tells the LLM to use
`offset`/`limit`
- Existing byte-based `/read-file` endpoint preserved (used by
`instruction.go`)

### SDK (`codersdk/workspacesdk/`)
- `ReadFileLinesResponse` type added
- `ReadFileLines` method added to `AgentConn` interface
- Mock regenerated

### Chat tool (`coderd/chatd/chattool/`)
- `read_file` tool now uses `conn.ReadFileLines()` instead of
`conn.ReadFile()`
- Updated tool description to document line-based parameters
- Response includes `file_size`, `total_lines`, `lines_read` metadata

### MCP tool (`codersdk/toolsdk/`)
- `coder_workspace_read_file` updated to use line-based reading
- Schema descriptions updated for line-based offset/limit
- Removed `maxFileLimit` constant (agent handles limits now)

### Tests
- 13 new test cases for `TestReadFileLines`:
- Path validation (empty, relative, non-existent, directory, no
permissions)
  - Empty file handling
  - Basic read, offset, limit, offset+limit combinations
  - Offset beyond file length
  - Long line truncation (>1024 bytes)
  - Large file rejection (>1MB)
- All existing tests pass unchanged

## Design decisions

| Decision | Rationale |
|---|---|
| Line-based, not byte-based | Both coder/mux and openai/codex use
line-based — matches how LLMs reason about code |
| Default limit of 2000 | Matches codex; prevents accidental full-file
dumps while being generous |
| 32 KB response cap | Compromise between mux (16 KB) and codex (no cap)
|
| 1024 byte/line truncation with marker | More generous than codex
(500), marker helps LLM know data is missing |
| Hard errors on overflow | Matches mux; forces LLM to paginate rather
than getting partial data |
| Preserve byte-based endpoint | `instruction.go` needs raw byte access
for AGENTS.md |
This commit is contained in:
Kyle Carberry
2026-02-27 15:12:56 -05:00
committed by GitHub
parent ff687aa780
commit b65c0766d2
6 changed files with 462 additions and 11 deletions
+13 -11
View File
@@ -2,7 +2,6 @@ package chattool
import (
"context"
"io"
"charm.land/fantasy"
@@ -22,7 +21,10 @@ type ReadFileArgs struct {
func ReadFile(options ReadFileOptions) fantasy.AgentTool {
return fantasy.NewAgentTool(
"read_file",
"Read a file from the workspace.",
"Read a file from the workspace. Returns line-numbered content. "+
"The offset parameter is a 1-based line number (default: 1). "+
"The limit parameter is the number of lines to return (default: 2000). "+
"For large files, use offset and limit to paginate.",
func(ctx context.Context, args ReadFileArgs, _ fantasy.ToolCall) (fantasy.ToolResponse, error) {
if options.GetWorkspaceConn == nil {
return fantasy.NewTextErrorResponse("workspace connection resolver is not configured"), nil
@@ -45,8 +47,8 @@ func executeReadFileTool(
return fantasy.NewTextErrorResponse("path is required"), nil
}
offset := int64(0)
limit := int64(0)
offset := int64(1) // 1-based line number default
limit := int64(0) // 0 means use server default (2000)
if args.Offset != nil {
offset = *args.Offset
}
@@ -54,19 +56,19 @@ func executeReadFileTool(
limit = *args.Limit
}
reader, mimeType, err := conn.ReadFile(ctx, args.Path, offset, limit)
resp, err := conn.ReadFileLines(ctx, args.Path, offset, limit, workspacesdk.DefaultReadFileLinesLimits())
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
return fantasy.NewTextErrorResponse(err.Error()), nil
if !resp.Success {
return fantasy.NewTextErrorResponse(resp.Error), nil
}
return toolResponse(map[string]any{
"content": string(data),
"mime_type": mimeType,
"content": resp.Content,
"file_size": resp.FileSize,
"total_lines": resp.TotalLines,
"lines_read": resp.LinesRead,
}), nil
}