diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index df641c07ff..6211e667bb 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -4,7 +4,7 @@ import { Provider } from "../provider/provider" import { generateObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" -import { Truncate } from "../session/truncation" +import { Truncate } from "../tool/truncation" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index a0f50129e5..5674251748 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -11,6 +11,7 @@ import { Identifier } from "../id/id" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 +const MAX_BYTES = 50 * 1024 export const ReadTool = Tool.define("read", { description: DESCRIPTION, @@ -77,6 +78,7 @@ export const ReadTool = Tool.define("read", { output: msg, metadata: { preview: msg, + truncated: false, }, attachments: [ { @@ -97,9 +99,21 @@ export const ReadTool = Tool.define("read", { const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 const lines = await file.text().then((text) => text.split("\n")) - const raw = lines.slice(offset, offset + limit).map((line) => { - return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line - }) + + const raw: string[] = [] + let bytes = 0 + let truncatedByBytes = false + for (let i = offset; i < Math.min(lines.length, offset + limit); i++) { + const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i] + const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0) + if (bytes + size > MAX_BYTES) { + truncatedByBytes = true + break + } + raw.push(line) + bytes += size + } + const content = raw.map((line, index) => { return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}` }) @@ -109,10 +123,13 @@ export const ReadTool = Tool.define("read", { output += content.join("\n") const totalLines = lines.length - const lastReadLine = offset + content.length + const lastReadLine = offset + raw.length const hasMoreLines = totalLines > lastReadLine + const truncated = hasMoreLines || truncatedByBytes - if (hasMoreLines) { + if (truncatedByBytes) { + output += `\n\n(Output truncated at ${MAX_BYTES} bytes. Use 'offset' parameter to read beyond line ${lastReadLine})` + } else if (hasMoreLines) { output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})` } else { output += `\n\n(End of file - total ${totalLines} lines)` @@ -128,6 +145,7 @@ export const ReadTool = Tool.define("read", { output, metadata: { preview, + truncated, }, } }, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 96432369f6..af9a896c64 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -23,7 +23,7 @@ import { CodeSearchTool } from "./codesearch" import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { LspTool } from "./lsp" -import { Truncate } from "../session/truncation" +import { Truncate } from "./truncation" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 7545d36b1a..113a7a3c07 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -2,7 +2,7 @@ import z from "zod" import type { MessageV2 } from "../session/message-v2" import type { Agent } from "../agent/agent" import type { PermissionNext } from "../permission/next" -import { Truncate } from "../session/truncation" +import { Truncate } from "./truncation" export namespace Tool { interface Metadata { @@ -66,6 +66,10 @@ export namespace Tool { ) } const result = await execute(args, ctx) + // skip truncation for tools that handle it themselves + if (result.metadata.truncated !== undefined) { + return result + } const truncated = await Truncate.output(result.output, {}, initCtx?.agent) return { ...result, diff --git a/packages/opencode/src/session/truncation.ts b/packages/opencode/src/tool/truncation.ts similarity index 100% rename from packages/opencode/src/session/truncation.ts rename to packages/opencode/src/tool/truncation.ts diff --git a/packages/opencode/test/session/fixtures/models-api.json b/packages/opencode/test/tool/fixtures/models-api.json similarity index 100% rename from packages/opencode/test/session/fixtures/models-api.json rename to packages/opencode/test/tool/fixtures/models-api.json diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 826fa03f6c..a88d25f73a 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -6,6 +6,8 @@ import { tmpdir } from "../fixture/fixture" import { PermissionNext } from "../../src/permission/next" import { Agent } from "../../src/agent/agent" +const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") + const ctx = { sessionID: "test", messageID: "", @@ -165,3 +167,123 @@ describe("tool.read env file blocking", () => { }) }) }) + +describe("tool.read truncation", () => { + test("truncates large file by bytes and sets truncated metadata", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text() + await Bun.write(path.join(dir, "large.json"), content) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "large.json") }, ctx) + expect(result.metadata.truncated).toBe(true) + expect(result.output).toContain("Output truncated at") + expect(result.output).toContain("bytes") + }, + }) + }) + + test("truncates by line count when limit is specified", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") + await Bun.write(path.join(dir, "many-lines.txt"), lines) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx) + expect(result.metadata.truncated).toBe(true) + expect(result.output).toContain("File has more lines") + expect(result.output).toContain("line0") + expect(result.output).toContain("line9") + expect(result.output).not.toContain("line10") + }, + }) + }) + + test("does not truncate small file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "small.txt"), "hello world") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "small.txt") }, ctx) + expect(result.metadata.truncated).toBe(false) + expect(result.output).toContain("End of file") + }, + }) + }) + + test("respects offset parameter", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const lines = Array.from({ length: 20 }, (_, i) => `line${i}`).join("\n") + await Bun.write(path.join(dir, "offset.txt"), lines) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx) + expect(result.output).toContain("line10") + expect(result.output).toContain("line14") + expect(result.output).not.toContain("line0") + expect(result.output).not.toContain("line15") + }, + }) + }) + + test("truncates long lines", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const longLine = "x".repeat(3000) + await Bun.write(path.join(dir, "long-line.txt"), longLine) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "long-line.txt") }, ctx) + expect(result.output).toContain("...") + expect(result.output.length).toBeLessThan(3000) + }, + }) + }) + + test("image files set truncated to false", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // 1x1 red PNG + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", + "base64", + ) + await Bun.write(path.join(dir, "image.png"), png) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "image.png") }, ctx) + expect(result.metadata.truncated).toBe(false) + expect(result.attachments).toBeDefined() + expect(result.attachments?.length).toBe(1) + }, + }) + }) +}) diff --git a/packages/opencode/test/session/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts similarity index 98% rename from packages/opencode/test/session/truncation.test.ts rename to packages/opencode/test/tool/truncation.test.ts index 242109f5c3..9560f53891 100644 --- a/packages/opencode/test/session/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { Truncate } from "../../src/session/truncation" +import { Truncate } from "../../src/tool/truncation" import path from "path" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")