Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 53 additions & 9 deletions packages/opencode/src/tool/truncation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export namespace Truncate {
export interface Options {
maxLines?: number
maxBytes?: number
direction?: "head" | "tail"
direction?: "head" | "tail" | "middle"
}

export function init() {
Expand Down Expand Up @@ -50,14 +50,65 @@ export namespace Truncate {
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const direction = options.direction ?? "head"
const direction = options.direction ?? "middle"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")

if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false }
}

const id = Identifier.ascending("tool")
const filepath = path.join(DIR, id)
await Bun.write(Bun.file(filepath), text)

const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`

if (direction === "middle") {
const HEAD_SHARE = 0.3
const headMaxLines = Math.max(1, Math.floor(maxLines * HEAD_SHARE))
const tailMaxLines = Math.max(1, maxLines - headMaxLines)
const headMaxBytes = Math.floor(maxBytes * HEAD_SHARE)
const tailMaxBytes = maxBytes - headMaxBytes

const headOut: string[] = []
let headBytes = 0
let headHitBytes = false
for (let i = 0; i < lines.length && headOut.length < headMaxLines; i++) {
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
if (headBytes + size > headMaxBytes) {
headHitBytes = true
break
}
headOut.push(lines[i])
headBytes += size
}

const tailOut: string[] = []
let tailBytes = 0
let tailHitBytes = false
for (let i = lines.length - 1; i >= headOut.length && tailOut.length < tailMaxLines; i--) {
const size = Buffer.byteLength(lines[i], "utf-8") + (tailOut.length > 0 ? 1 : 0)
if (tailBytes + size > tailMaxBytes) {
tailHitBytes = true
break
}
tailOut.unshift(lines[i])
tailBytes += size
}

const keptLines = headOut.length + tailOut.length
const keptBytes = headBytes + tailBytes
const hitBytes = headHitBytes || tailHitBytes
const removed = hitBytes ? totalBytes - keptBytes : lines.length - keptLines
const unit = hitBytes ? "bytes" : "lines"

const message = `${headOut.join("\n")}\n\n...${removed} ${unit} truncated...\n\n${hint}\n\n${tailOut.join("\n")}`
return { content: message, truncated: true, outputPath: filepath }
}

const out: string[] = []
let i = 0
let bytes = 0
Expand Down Expand Up @@ -89,13 +140,6 @@ export namespace Truncate {
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")

const id = Identifier.ascending("tool")
const filepath = path.join(DIR, id)
await Bun.write(Bun.file(filepath), text)

const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
: `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
const message =
direction === "head"
? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}`
Expand Down
38 changes: 36 additions & 2 deletions packages/opencode/test/tool/truncation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,26 @@ describe("Truncate", () => {
expect(result.content).toContain("truncated...")
})

test("truncates from head by default", async () => {
test("truncates from middle by default, keeping head and tail", async () => {
const lines = Array.from({ length: 20 }, (_, i) => `line${i}`).join("\n")
const result = await Truncate.output(lines, { maxLines: 10 })

expect(result.truncated).toBe(true)
// Head portion (~30%) should be kept
expect(result.content).toContain("line0")
expect(result.content).toContain("line1")
expect(result.content).toContain("line2")
// Tail portion (~70%) should be kept
expect(result.content).toContain("line19")
expect(result.content).toContain("line18")
expect(result.content).toContain("line17")
// Middle should be truncated
expect(result.content).toContain("truncated")
})

test("truncates from head when direction is head", async () => {
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
const result = await Truncate.output(lines, { maxLines: 3 })
const result = await Truncate.output(lines, { maxLines: 3, direction: "head" })

expect(result.truncated).toBe(true)
expect(result.content).toContain("line0")
Expand All @@ -63,6 +80,23 @@ describe("Truncate", () => {
expect(result.content).not.toContain("line0")
})

test("middle truncation prioritizes tail over head (70/30 split)", async () => {
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = await Truncate.output(lines, { maxLines: 10, direction: "middle" })

expect(result.truncated).toBe(true)
// Head gets ~30% = 3 lines
expect(result.content).toContain("line0")
expect(result.content).toContain("line1")
expect(result.content).toContain("line2")
// Tail gets ~70% = 7 lines
expect(result.content).toContain("line99")
expect(result.content).toContain("line98")
expect(result.content).toContain("line93")
// Middle should not be present
expect(result.content).not.toContain("line50")
})

test("uses default MAX_LINES and MAX_BYTES", () => {
expect(Truncate.MAX_LINES).toBe(2000)
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
Expand Down
Loading