summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/src/tool/bash.ts121
-rw-r--r--packages/opencode/src/tool/truncate.ts15
-rw-r--r--packages/opencode/test/session/prompt-effect.test.ts4
-rw-r--r--packages/opencode/test/tool/bash.test.ts8
4 files changed, 132 insertions, 16 deletions
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index 7a124dada..0ab130130 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -1,5 +1,6 @@
import z from "zod"
import os from "os"
+import { createWriteStream } from "node:fs"
import { Tool } from "./tool"
import path from "path"
import DESCRIPTION from "./bash.txt"
@@ -76,6 +77,11 @@ type Scan = {
always: Set<string>
}
+type Chunk = {
+ text: string
+ size: number
+}
+
export const log = Log.create({ service: "bash-tool" })
const resolveWasm = (asset: string) => {
@@ -211,7 +217,39 @@ function pathArgs(list: Part[], ps: boolean) {
function preview(text: string) {
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) {
@@ -295,6 +333,7 @@ export const BashTool = Tool.define(
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const fs = yield* AppFileSystem.Service
+ const trunc = yield* Truncate.Service
const plugin = yield* Plugin.Service
const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
@@ -381,7 +420,16 @@ export const BashTool = Tool.define(
},
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 aborted = false
@@ -398,10 +446,47 @@ export const BashTool = Tool.define(
yield* Effect.forkScoped(
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({
metadata: {
- output: preview(output),
+ output: last,
description: input.description,
},
})
@@ -443,16 +528,42 @@ export const BashTool = Tool.define(
)
}
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) {
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 {
title: input.description,
metadata: {
- output: preview(output),
+ output: last || preview(output),
exit: code,
description: input.description,
+ truncated: cut,
+ ...(cut && file ? { outputPath: file } : {}),
},
output,
}
diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts
index a7bd8a4b1..d607e22f2 100644
--- a/packages/opencode/src/tool/truncate.ts
+++ b/packages/opencode/src/tool/truncate.ts
@@ -33,6 +33,7 @@ export namespace Truncate {
export interface Interface {
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
* 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 maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
@@ -102,10 +110,7 @@ export namespace Truncate {
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "bytes" : "lines"
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)
? `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,
)
- return Service.of({ cleanup, output })
+ return Service.of({ cleanup, write, output })
}),
)
diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts
index 94561206e..31727e3df 100644
--- a/packages/opencode/test/session/prompt-effect.test.ts
+++ b/packages/opencode/test/session/prompt-effect.test.ts
@@ -1362,8 +1362,8 @@ unix(
expect(tool.state.metadata.truncated).toBe(true)
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")
}),
{ git: true, config: providerCfg },
diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts
index 3b03da57e..19135ba98 100644
--- a/packages/opencode/test/tool/bash.test.ts
+++ b/packages/opencode/test/tool/bash.test.ts
@@ -1116,8 +1116,8 @@ describe("tool.bash truncation", () => {
),
)
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)
- 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+/)
},
})
})