Aiden Cline 5 дней назад
Родитель
Сommit
307251bf3c

+ 116 - 5
packages/opencode/src/tool/bash.ts

@@ -1,5 +1,6 @@
 import z from "zod"
 import z from "zod"
 import os from "os"
 import os from "os"
+import { createWriteStream } from "node:fs"
 import { Tool } from "./tool"
 import { Tool } from "./tool"
 import path from "path"
 import path from "path"
 import DESCRIPTION from "./bash.txt"
 import DESCRIPTION from "./bash.txt"
@@ -76,6 +77,11 @@ type Scan = {
   always: Set<string>
   always: Set<string>
 }
 }
 
 
+type Chunk = {
+  text: string
+  size: number
+}
+
 export const log = Log.create({ service: "bash-tool" })
 export const log = Log.create({ service: "bash-tool" })
 
 
 const resolveWasm = (asset: string) => {
 const resolveWasm = (asset: string) => {
@@ -211,7 +217,39 @@ function pathArgs(list: Part[], ps: boolean) {
 
 
 function preview(text: string) {
 function preview(text: string) {
   if (text.length <= MAX_METADATA_LENGTH) return text
   if (text.length <= MAX_METADATA_LENGTH) return text
-  return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..."
+  return "...\n\n" + text.slice(-MAX_METADATA_LENGTH)
+}
+
+function tail(text: string, maxLines: number, maxBytes: number) {
+  const lines = text.split("\n")
+  if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes) {
+    return {
+      text,
+      cut: false,
+    }
+  }
+
+  const out: string[] = []
+  let bytes = 0
+  for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
+    const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
+    if (bytes + size > maxBytes) {
+      if (out.length === 0) {
+        const buf = Buffer.from(lines[i], "utf-8")
+        let start = buf.length - maxBytes
+        if (start < 0) start = 0
+        while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++
+        out.unshift(buf.subarray(start).toString("utf-8"))
+      }
+      break
+    }
+    out.unshift(lines[i])
+    bytes += size
+  }
+  return {
+    text: out.join("\n"),
+    cut: true,
+  }
 }
 }
 
 
 const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) {
 const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) {
@@ -295,6 +333,7 @@ export const BashTool = Tool.define(
   Effect.gen(function* () {
   Effect.gen(function* () {
     const spawner = yield* ChildProcessSpawner
     const spawner = yield* ChildProcessSpawner
     const fs = yield* AppFileSystem.Service
     const fs = yield* AppFileSystem.Service
+    const trunc = yield* Truncate.Service
     const plugin = yield* Plugin.Service
     const plugin = yield* Plugin.Service
 
 
     const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
     const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
@@ -381,7 +420,16 @@ export const BashTool = Tool.define(
       },
       },
       ctx: Tool.Context,
       ctx: Tool.Context,
     ) {
     ) {
-      let output = ""
+      const bytes = Truncate.MAX_BYTES
+      const lines = Truncate.MAX_LINES
+      const keep = bytes * 2
+      let full = ""
+      let last = ""
+      const list: Chunk[] = []
+      let used = 0
+      let file = ""
+      let sink: ReturnType<typeof createWriteStream> | undefined
+      let cut = false
       let expired = false
       let expired = false
       let aborted = false
       let aborted = false
 
 
@@ -398,10 +446,47 @@ export const BashTool = Tool.define(
 
 
           yield* Effect.forkScoped(
           yield* Effect.forkScoped(
             Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
             Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
-              output += chunk
+              const size = Buffer.byteLength(chunk, "utf-8")
+              list.push({ text: chunk, size })
+              used += size
+              while (used > keep && list.length > 1) {
+                const item = list.shift()
+                if (!item) break
+                used -= item.size
+                cut = true
+              }
+
+              last = preview(last + chunk)
+
+              if (file) {
+                sink?.write(chunk)
+              } else {
+                full += chunk
+                if (Buffer.byteLength(full, "utf-8") > bytes) {
+                  return trunc.write(full).pipe(
+                    Effect.andThen((next) =>
+                      Effect.sync(() => {
+                        file = next
+                        cut = true
+                        sink = createWriteStream(next, { flags: "a" })
+                        full = ""
+                      }),
+                    ),
+                    Effect.andThen(
+                      ctx.metadata({
+                        metadata: {
+                          output: last,
+                          description: input.description,
+                        },
+                      }),
+                    ),
+                  )
+                }
+              }
+
               return ctx.metadata({
               return ctx.metadata({
                 metadata: {
                 metadata: {
-                  output: preview(output),
+                  output: last,
                   description: input.description,
                   description: input.description,
                 },
                 },
               })
               })
@@ -443,16 +528,42 @@ export const BashTool = Tool.define(
         )
         )
       }
       }
       if (aborted) meta.push("User aborted the command")
       if (aborted) meta.push("User aborted the command")
+      const raw = list.map((item) => item.text).join("")
+      const end = tail(raw, lines, bytes)
+      if (end.cut) cut = true
+      if (!file && end.cut) {
+        file = yield* trunc.write(raw)
+      }
+
+      let output = end.text
+      if (!output) output = "(no output)"
+
+      if (cut && file) {
+        output = `...output truncated...\n\nFull output saved to: ${file}\n\n` + output
+      }
+
       if (meta.length > 0) {
       if (meta.length > 0) {
         output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
         output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
       }
       }
+      if (sink) {
+        const stream = sink
+        yield* Effect.promise(
+          () =>
+            new Promise<void>((resolve) => {
+              stream.end(() => resolve())
+              stream.on("error", () => resolve())
+            }),
+        )
+      }
 
 
       return {
       return {
         title: input.description,
         title: input.description,
         metadata: {
         metadata: {
-          output: preview(output),
+          output: last || preview(output),
           exit: code,
           exit: code,
           description: input.description,
           description: input.description,
+          truncated: cut,
+          ...(cut && file ? { outputPath: file } : {}),
         },
         },
         output,
         output,
       }
       }

+ 10 - 5
packages/opencode/src/tool/truncate.ts

@@ -33,6 +33,7 @@ export namespace Truncate {
 
 
   export interface Interface {
   export interface Interface {
     readonly cleanup: () => Effect.Effect<void>
     readonly cleanup: () => Effect.Effect<void>
+    readonly write: (text: string) => Effect.Effect<string>
     /**
     /**
      * Returns output unchanged when it fits within the limits, otherwise writes the full text
      * Returns output unchanged when it fits within the limits, otherwise writes the full text
      * to the truncation directory and returns a preview plus a hint to inspect the saved file.
      * to the truncation directory and returns a preview plus a hint to inspect the saved file.
@@ -61,6 +62,13 @@ export namespace Truncate {
         }
         }
       })
       })
 
 
+      const write = Effect.fn("Truncate.write")(function* (text: string) {
+        const file = path.join(TRUNCATION_DIR, ToolID.ascending())
+        yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
+        yield* fs.writeFileString(file, text).pipe(Effect.orDie)
+        return file
+      })
+
       const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
       const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
         const maxLines = options.maxLines ?? MAX_LINES
         const maxLines = options.maxLines ?? MAX_LINES
         const maxBytes = options.maxBytes ?? MAX_BYTES
         const maxBytes = options.maxBytes ?? MAX_BYTES
@@ -102,10 +110,7 @@ export namespace Truncate {
         const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
         const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
         const unit = hitBytes ? "bytes" : "lines"
         const unit = hitBytes ? "bytes" : "lines"
         const preview = out.join("\n")
         const preview = out.join("\n")
-        const file = path.join(TRUNCATION_DIR, ToolID.ascending())
-
-        yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
-        yield* fs.writeFileString(file, text).pipe(Effect.orDie)
+        const file = yield* write(text)
 
 
         const hint = hasTaskTool(agent)
         const hint = hasTaskTool(agent)
           ? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\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: ${file}\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.`
@@ -131,7 +136,7 @@ export namespace Truncate {
         Effect.forkScoped,
         Effect.forkScoped,
       )
       )
 
 
-      return Service.of({ cleanup, output })
+      return Service.of({ cleanup, write, output })
     }),
     }),
   )
   )
 
 

+ 2 - 2
packages/opencode/test/session/prompt-effect.test.ts

@@ -1362,8 +1362,8 @@ unix(
 
 
           expect(tool.state.metadata.truncated).toBe(true)
           expect(tool.state.metadata.truncated).toBe(true)
           expect(typeof tool.state.metadata.outputPath).toBe("string")
           expect(typeof tool.state.metadata.outputPath).toBe("string")
-          expect(tool.state.output).toContain("The tool call succeeded but the output was truncated.")
-          expect(tool.state.output).toContain("Full output saved to:")
+          expect(tool.state.output).toMatch(/\.\.\.output truncated\.\.\./)
+          expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/)
           expect(tool.state.output).not.toContain("Tool execution aborted")
           expect(tool.state.output).not.toContain("Tool execution aborted")
         }),
         }),
       { git: true, config: providerCfg },
       { git: true, config: providerCfg },

+ 4 - 4
packages/opencode/test/tool/bash.test.ts

@@ -1116,8 +1116,8 @@ describe("tool.bash truncation", () => {
           ),
           ),
         )
         )
         mustTruncate(result)
         mustTruncate(result)
-        expect(result.output).toContain("truncated")
-        expect(result.output).toContain("The tool call succeeded but the output was truncated")
+        expect(result.output).toMatch(/\.\.\.output truncated\.\.\./)
+        expect(result.output).toMatch(/Full output saved to:\s+\S+/)
       },
       },
     })
     })
   })
   })
@@ -1138,8 +1138,8 @@ describe("tool.bash truncation", () => {
           ),
           ),
         )
         )
         mustTruncate(result)
         mustTruncate(result)
-        expect(result.output).toContain("truncated")
-        expect(result.output).toContain("The tool call succeeded but the output was truncated")
+        expect(result.output).toMatch(/\.\.\.output truncated\.\.\./)
+        expect(result.output).toMatch(/Full output saved to:\s+\S+/)
       },
       },
     })
     })
   })
   })