diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 7a124dadae..0ab1301305 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 } +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 | 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\n" + meta.join("\n") + "\n" } + if (sink) { + const stream = sink + yield* Effect.promise( + () => + new Promise((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 a7bd8a4b16..d607e22f28 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 + readonly write: (text: string) => Effect.Effect /** * 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 94561206e2..31727e3df9 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 3b03da57ee..19135ba98b 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+/) }, }) })