Skip to content

Commit d2418d6

Browse files
authored
Add read tool output guardrails (#4598)
Adds bounded, line-numbered output to the agents runtime `read` tool so file reads no longer dump up to 512KB of raw text into model context. Users can page through files with `offset`/`limit`, get explicit continuation guidance, and the edit tool no longer requires a prior read before applying an exact string replacement. ## Root Cause The read tool had a 512KB hard file-size cap, but successful reads under that limit returned the entire file. That made medium-sized files expensive in context and gave agents no first-class way to inspect specific line ranges except by falling back to shell commands. The edit tool also enforced a same-wake prior-read check. That added friction without much safety: an edit is already constrained by whether the exact `old_string` patch applies uniquely, or by `replace_all=true` when requested. ## Approach - Add optional `offset` and `limit` parameters to `read`. - Render file contents with line numbers and XML-ish path/content wrappers. - Bound returned file content to 2000 lines and 50KB by default. - Truncate individual lines longer than 2000 characters. - Include continuation hints such as `Use offset=2001 to continue` when output is partial. - Preserve existing safeguards for sandbox containment, binary files, and files over 512KB. - Remove the edit tool's read-set gate while keeping exact-match and multi-match protections. - Update Horton prompt/tool descriptions so agents are no longer told they must read before editing. ## Key Invariants - A single read call should have a predictable context footprint. - Truncation must be explicit, never silent. - Edits should only apply when `old_string` is found exactly once, unless `replace_all=true` is provided. - Existing binary and oversized-file rejection behavior should remain intact. ## Non-goals - This does not add true streaming/ranged filesystem reads; the current sandbox API still reads the file after the existing 512KB stat gate. - This does not add `.hortonignore`/secret deny rules. - This does not broaden edit semantics beyond existing exact string replacement behavior. ## Trade-offs The implementation keeps the existing 512KB file-size gate instead of introducing sandbox-level streaming. That makes the change smaller and safer, while still reducing the returned tool output from as much as 512KB to a bounded page. A future improvement could move truncation earlier into the sandbox read path for larger-file previews. Removing the prior-read gate relies on the patch application itself for safety: if the exact text is absent or ambiguous, the edit is rejected. This is simpler and avoids forcing unnecessary reads solely to satisfy runtime state. ## Verification ```bash cd packages/agents-runtime pnpm exec vitest run test/read-file-tool.test.ts test/write-edit-roundtrip.test.ts test/edit-tool-read-guard.test.ts test/sandbox-tool-refactor.test.ts test/readset-isolation.test.ts test/sandbox-tool-symlink-safety.test.ts pnpm run typecheck cd ../agents pnpm exec vitest run test/horton-system-prompt.test.ts pnpm run typecheck ``` Also attempted broader agents tests; unrelated bootstrap/pull-wake tests failed locally with undici cache `invalid onError method` errors after building runtime dist. ## Files changed - `packages/agents-runtime/src/tools/read-file.ts` — adds offset/limit params, line-numbered bounded output, truncation metadata, and empty-file/trailing-newline handling. - `packages/agents-runtime/src/tools/edit.ts` — removes the same-session prior-read gate while preserving exact-match replacement behavior. - `packages/agents-runtime/test/read-file-tool.test.ts` — adds focused coverage for default paging, offset/limit, byte/line truncation, and empty/trailing-newline handling. - `packages/agents-runtime/test/edit-tool-read-guard.test.ts` and `readset-isolation.test.ts` — update coverage for edit behavior without read-set enforcement. - `packages/agents/src/agents/horton.ts` — removes prompt guidance claiming edits require prior reads. - `.changeset/horton-read-file-guardrails.md` — patch changeset for affected agents packages.
1 parent fcc5864 commit d2418d6

17 files changed

Lines changed: 389 additions & 250 deletions
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@electric-ax/agents-runtime': patch
3+
'@electric-ax/agents': patch
4+
---
5+
6+
Add bounded, line-numbered read tool output with offset/limit support to reduce context bloat, and remove the edit tool's prior-read requirement.

‎packages/agents-runtime/src/tools/edit.ts‎

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,17 @@ import { SandboxError } from '../sandbox/types'
66
import type { Sandbox } from '../sandbox/types'
77
import type { AgentTool } from '@mariozechner/pi-agent-core'
88

9-
const READ_GUARD_MESSAGE = (rel: string): string =>
10-
`File ${rel} has not been read in this session (sessions are per-wake — re-read after waking from a worker).`
11-
12-
export function createEditTool(
13-
sandbox: Sandbox,
14-
readSet: Set<string>
15-
): AgentTool {
9+
export function createEditTool(sandbox: Sandbox): AgentTool {
1610
return {
1711
name: `edit`,
1812
label: `Edit File`,
19-
description: `Replace text in a file. The file must have been read with the read tool earlier in this session. By default the old_string must occur exactly once; set replace_all to true to replace every occurrence.`,
13+
description: `Replace text in a file. By default the old_string must occur exactly once; set replace_all to true to replace every occurrence.`,
2014
parameters: Type.Object({
2115
path: Type.String({
2216
description: `File path (relative to working directory)`,
2317
}),
2418
old_string: Type.String({
25-
description: `The literal text to find. Must be unique unless replace_all is true.`,
19+
description: `The literal text to find, exactly as it appears in the file. Do not include read tool line-number prefixes like "42: "; those are display-only. Must be unique unless replace_all is true.`,
2620
}),
2721
new_string: Type.String({
2822
description: `The replacement text.`,
@@ -45,20 +39,9 @@ export function createEditTool(
4539
new_string: string
4640
replace_all?: boolean
4741
}
48-
// `key`/`rel` are pure-string normalizations (readSet key + messages);
49-
// an out-of-workspace path can't have been read, so the read-guard
50-
// below naturally refuses it. Symlink/containment escapes are caught
51-
// from the sandbox's FS calls as SandboxError('policy').
5242
const key = resolve(sandbox.workingDirectory, filePath)
5343
const rel = relative(sandbox.workingDirectory, key)
5444
try {
55-
if (!readSet.has(key)) {
56-
return {
57-
content: [{ type: `text` as const, text: READ_GUARD_MESSAGE(rel) }],
58-
details: { replacements: 0 },
59-
}
60-
}
61-
6245
const original = (await sandbox.readFile(filePath)).toString(`utf-8`)
6346

6447
if (!replace_all) {

‎packages/agents-runtime/src/tools/read-file.ts‎

Lines changed: 160 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,160 @@
1-
import { resolve } from 'node:path'
21
import { Type } from '@sinclair/typebox'
32
import { runtimeLog } from '../log'
43
import { SandboxError } from '../sandbox/types'
54
import type { Sandbox } from '../sandbox/types'
65
import type { AgentTool } from '@mariozechner/pi-agent-core'
76

87
const MAX_FILE_SIZE = 512 * 1024 // 512 KB
8+
const DEFAULT_READ_LIMIT_LINES = 2_000
9+
const MAX_OUTPUT_BYTES = 50 * 1024
10+
const MAX_OUTPUT_BYTES_LABEL = `${MAX_OUTPUT_BYTES / 1024} KB`
11+
const MAX_LINE_LENGTH = 2_000
12+
const BINARY_SAMPLE_BYTES = 8 * 1024
13+
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
14+
15+
function positiveInteger(value: unknown, fallback: number): number {
16+
if (typeof value !== `number` || !Number.isFinite(value)) return fallback
17+
return Math.max(1, Math.floor(value))
18+
}
19+
20+
function splitLines(text: string): Array<string> {
21+
const lines = text.split(/\r?\n/)
22+
if (lines.length > 1 && lines.at(-1) === ``) lines.pop()
23+
return lines
24+
}
25+
26+
function formatReadOutput(
27+
filePath: string,
28+
text: string,
29+
opts: { offset: number; limit: number }
30+
): {
31+
text: string
32+
charCount: number
33+
shownBytes: number
34+
startLine: number
35+
endLine: number
36+
shownLines: number
37+
totalLines: number
38+
truncated: boolean
39+
} {
40+
const lines = splitLines(text)
41+
const totalLines = lines.length === 1 && lines[0] === `` ? 0 : lines.length
42+
43+
if (totalLines === 0) {
44+
const output = [
45+
`<path>${filePath}</path>`,
46+
`<content>`,
47+
`(Empty file)`,
48+
`</content>`,
49+
].join(`\n`)
50+
return {
51+
text: output,
52+
charCount: output.length,
53+
shownBytes: 0,
54+
startLine: 0,
55+
endLine: 0,
56+
shownLines: 0,
57+
totalLines: 0,
58+
truncated: false,
59+
}
60+
}
61+
62+
const startIndex = opts.offset - 1
63+
if (startIndex >= totalLines) {
64+
return {
65+
text: `Error: Offset ${opts.offset} is out of range for this file (${totalLines} lines)`,
66+
charCount: 0,
67+
shownBytes: 0,
68+
startLine: opts.offset,
69+
endLine: opts.offset,
70+
shownLines: 0,
71+
totalLines,
72+
truncated: false,
73+
}
74+
}
75+
76+
const renderedLines: Array<string> = []
77+
let shownBytes = 0
78+
let stoppedByBytes = false
79+
let endLine = opts.offset - 1
80+
81+
for (let index = startIndex; index < totalLines; index++) {
82+
if (renderedLines.length >= opts.limit) break
83+
84+
const rawLine = lines[index] ?? ``
85+
const displayLine =
86+
rawLine.length > MAX_LINE_LENGTH
87+
? rawLine.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX
88+
: rawLine
89+
const rendered = `${index + 1}: ${displayLine}`
90+
const renderedBytes =
91+
Buffer.byteLength(rendered, `utf8`) + (renderedLines.length > 0 ? 1 : 0)
92+
93+
if (shownBytes + renderedBytes > MAX_OUTPUT_BYTES) {
94+
stoppedByBytes = true
95+
break
96+
}
97+
98+
renderedLines.push(rendered)
99+
shownBytes += renderedBytes
100+
endLine = index + 1
101+
}
102+
103+
const nextLine = endLine + 1
104+
const hasMoreLines = nextLine <= totalLines
105+
const truncated = stoppedByBytes || hasMoreLines
106+
let output = [`<path>${filePath}</path>`, `<content>`].join(`\n`)
107+
if (renderedLines.length > 0) output += `\n${renderedLines.join(`\n`)}`
108+
109+
if (stoppedByBytes) {
110+
output += `\n\n(Output capped at ${MAX_OUTPUT_BYTES_LABEL} of file content. Showing lines ${opts.offset}-${endLine}. Use offset=${nextLine} to continue.)`
111+
} else if (hasMoreLines) {
112+
output += `\n\n(Showing lines ${opts.offset}-${endLine} of ${totalLines}. Use offset=${nextLine} to continue.)`
113+
} else {
114+
output += `\n\n(End of file - total ${totalLines} lines)`
115+
}
116+
output += `\n</content>`
9117

10-
export function createReadFileTool(
11-
sandbox: Sandbox,
12-
readSet?: Set<string>
13-
): AgentTool {
118+
return {
119+
text: output,
120+
charCount: output.length,
121+
shownBytes,
122+
startLine: opts.offset,
123+
endLine,
124+
shownLines: renderedLines.length,
125+
totalLines,
126+
truncated,
127+
}
128+
}
129+
130+
export function createReadFileTool(sandbox: Sandbox): AgentTool {
14131
return {
15132
name: `read`,
16133
label: `Read File`,
17-
description: `Read the contents of a file. Path must be relative to or within the working directory. Binary files and files over 512KB are rejected.`,
134+
description: `Read the contents of a text file. Path must be relative to or within the working directory. Returns at most 2000 lines or 50KB of file content by default; use offset and limit to read specific sections. Binary files and files over 512KB are rejected. Lines longer than 2000 characters are truncated.`,
18135
parameters: Type.Object({
19136
path: Type.String({
20137
description: `File path (relative to working directory)`,
21138
}),
139+
offset: Type.Optional(
140+
Type.Integer({
141+
minimum: 1,
142+
description: `1-based line number to start reading from`,
143+
})
144+
),
145+
limit: Type.Optional(
146+
Type.Integer({
147+
minimum: 1,
148+
description: `Maximum number of lines to return; defaults to 2000`,
149+
})
150+
),
22151
}),
23152
execute: async (_toolCallId, params) => {
24-
const { path: filePath } = params as { path: string }
153+
const {
154+
path: filePath,
155+
offset: rawOffset,
156+
limit: rawLimit,
157+
} = params as { path: string; offset?: number; limit?: number }
25158
try {
26159
// Path resolution and workspace containment are the sandbox's job
27160
// (it owns the filesystem); a denied path rejects with
@@ -33,17 +166,17 @@ export function createReadFileTool(
33166
content: [
34167
{
35168
type: `text` as const,
36-
text: `Error: File is too large (${(fileStat.size / 1024).toFixed(0)}KB > ${MAX_FILE_SIZE / 1024}KB limit)`,
169+
text: `Error: File is too large (${(fileStat.size / 1024).toFixed(0)}KB > ${MAX_FILE_SIZE / 1024}KB limit). Use shell tools such as sed/head/tail for targeted inspection.`,
37170
},
38171
],
39-
details: { charCount: 0 },
172+
details: { charCount: 0, totalBytes: fileStat.size },
40173
}
41174
}
42175

43176
const buffer = await sandbox.readFile(filePath)
44177

45-
// Detect binary: check for null bytes in the first 8KB (same heuristic git/grep use).
46-
const sample = buffer.subarray(0, 8192)
178+
// Detect binary: check for null bytes in the first bytes (same basic heuristic git/grep use).
179+
const sample = buffer.subarray(0, BINARY_SAMPLE_BYTES)
47180
if (sample.includes(0)) {
48181
return {
49182
content: [
@@ -52,15 +185,27 @@ export function createReadFileTool(
52185
text: `Error: "${filePath}" appears to be a binary file`,
53186
},
54187
],
55-
details: { charCount: 0 },
188+
details: { charCount: 0, totalBytes: fileStat.size, binary: true },
56189
}
57190
}
58191

59192
const text = buffer.toString(`utf-8`)
60-
readSet?.add(resolve(sandbox.workingDirectory, filePath))
193+
const formatted = formatReadOutput(filePath, text, {
194+
offset: positiveInteger(rawOffset, 1),
195+
limit: positiveInteger(rawLimit, DEFAULT_READ_LIMIT_LINES),
196+
})
61197
return {
62-
content: [{ type: `text` as const, text }],
63-
details: { charCount: text.length },
198+
content: [{ type: `text` as const, text: formatted.text }],
199+
details: {
200+
charCount: formatted.charCount,
201+
totalBytes: fileStat.size,
202+
shownBytes: formatted.shownBytes,
203+
startLine: formatted.startLine,
204+
endLine: formatted.endLine,
205+
shownLines: formatted.shownLines,
206+
totalLines: formatted.totalLines,
207+
truncated: formatted.truncated,
208+
},
64209
}
65210
} catch (err) {
66211
if (err instanceof SandboxError && err.kind === `policy`) {

‎packages/agents-runtime/src/tools/write.ts‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@ import { SandboxError } from '../sandbox/types'
66
import type { Sandbox } from '../sandbox/types'
77
import type { AgentTool } from '@mariozechner/pi-agent-core'
88

9-
export function createWriteTool(
10-
sandbox: Sandbox,
11-
readSet?: Set<string>
12-
): AgentTool {
9+
export function createWriteTool(sandbox: Sandbox): AgentTool {
1310
return {
1411
name: `write`,
1512
label: `Write File`,
@@ -29,8 +26,7 @@ export function createWriteTool(
2926
}
3027
// Containment is enforced by the sandbox (it owns the filesystem);
3128
// an escaping path rejects with SandboxError('policy'), handled below.
32-
// `key`/`rel` are pure-string normalizations for the readSet and the
33-
// diff header — not a security check.
29+
// `key`/`rel` are pure-string normalizations for the diff header — not a security check.
3430
const key = resolve(sandbox.workingDirectory, filePath)
3531
const rel = relative(sandbox.workingDirectory, key)
3632
try {
@@ -43,7 +39,6 @@ export function createWriteTool(
4339

4440
await sandbox.mkdir(dirname(filePath), { recursive: true })
4541
await sandbox.writeFile(filePath, content)
46-
readSet?.add(key)
4742

4843
const bytesWritten = Buffer.byteLength(content, `utf-8`)
4944
const patch = createTwoFilesPatch(

0 commit comments

Comments
 (0)