Aiden Cline 3 месяцев назад
Родитель
Сommit
c47b976e67

+ 1 - 1
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"

+ 23 - 5
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,
       },
     }
   },

+ 1 - 1
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" })

+ 5 - 1
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,

+ 0 - 0
packages/opencode/src/session/truncation.ts → packages/opencode/src/tool/truncation.ts


+ 0 - 0
packages/opencode/test/session/fixtures/models-api.json → packages/opencode/test/tool/fixtures/models-api.json


+ 122 - 0
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)
+      },
+    })
+  })
+})

+ 1 - 1
packages/opencode/test/session/truncation.test.ts → 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")