diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9325583acf7..cc17f281bc2 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1,6 +1,7 @@ +import { Buffer } from "buffer" import path from "path" import os from "os" -import fs from "fs/promises" +import fs, { appendFile } from "fs/promises" import z from "zod" import { Identifier } from "../id/id" import { MessageV2 } from "./message-v2" @@ -1485,6 +1486,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the const matchingInvocation = invocations[shellName] ?? invocations[""] const args = matchingInvocation?.args + const tmpId = Identifier.ascending("tool") + const tmpPath = path.join(Truncate.DIR, tmpId) + const WINDOW_SIZE = 50 * 1024 + let output: Buffer | null = null + let outputSize = 0 + let truncated = false + let writer: ReturnType | null = null + const proc = spawn(shell, args, { cwd: Instance.directory, detached: process.platform !== "win32", @@ -1495,43 +1504,65 @@ NOTE: At any point in time through this workflow you should feel free to ask the }, }) - let output = "" - - proc.stdout?.on("data", (chunk) => { - output += chunk.toString() - if (part.state.status === "running") { - part.state.metadata = { - output: output, - description: "", + const handleChunk = (chunk: Buffer) => { + const str = chunk.toString() + outputSize += str.length + + if (outputSize > WINDOW_SIZE) { + truncated = true + if (!writer) { + const tmpFile = Bun.file(tmpPath) + writer = tmpFile.writer() + if (output) { + writer.write(output) + } + } + writer.write(chunk) + } else { + if (output) { + output = Buffer.concat([output, chunk]) + } else { + output = chunk } - Session.updatePart(part) } - }) - proc.stderr?.on("data", (chunk) => { - output += chunk.toString() - if (part.state.status === "running") { - part.state.metadata = { - output: output, - description: "", + if (output) { + const preview = output.toString() + if (part.state.status === "running") { + part.state.metadata = { + output: preview, + description: "", + } + Session.updatePart(part) } - Session.updatePart(part) } - }) + } + + proc.stdout?.on("data", handleChunk) + proc.stderr?.on("data", handleChunk) let aborted = false let exited = false const kill = () => Shell.killTree(proc, { exited: () => exited }) + const cleanupFile = async () => { + if (!truncated) return + try { + await fs.unlink(tmpPath) + } catch {} + } + if (abort.aborted) { aborted = true await kill() + await cleanupFile() } const abortHandler = () => { aborted = true void kill() + void cleanupFile() } abort.addEventListener("abort", abortHandler, { once: true }) @@ -1544,25 +1575,58 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) }) - if (aborted) { - output += "\n\n" + ["", "User aborted the command", ""].join("\n") + if (truncated) { + await writer!.flush() } + msg.time.completed = Date.now() await Session.updateMessage(msg) + if (part.state.status === "running") { - part.state = { - status: "completed", - time: { - ...part.state.time, - end: Date.now(), - }, - input: part.state.input, - title: "", - metadata: { - output, - description: "", - }, - output, + if (aborted) { + const metadataText = "\n\n" + ["", "User aborted the command", ""].join("\n") + if (truncated) { + await appendFile(tmpPath, metadataText) + } else if (output) { + output = Buffer.concat([output, Buffer.from(metadataText)]) + } else { + output = Buffer.from(metadataText) + } + } + + if (truncated) { + const userPreview = output ? output.toString() : "" + const agentPreview = `${userPreview}\n\n...${outputSize - WINDOW_SIZE} bytes truncated...\n\nThe tool call succeeded but the output was truncated. Full output saved to: ${tmpPath}\nGrep to search the full content or Read with offset/limit to view specific sections.\nIf Task tool is available, delegate that to an explore agent.` + part.state = { + status: "completed", + time: { + ...part.state.time, + end: Date.now(), + }, + input: part.state.input, + title: "", + metadata: { + output: userPreview, + description: "", + outputPath: tmpPath, + }, + output: agentPreview, + } + } else { + part.state = { + status: "completed", + time: { + ...part.state.time, + end: Date.now(), + }, + input: part.state.input, + title: "", + metadata: { + output: output ? output.toString("utf8") : "", + description: "", + }, + output: output ? output.toString("utf8") : "", + } } await Session.updatePart(part) } diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index f3a1b04d431..a38bfc74911 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -1,9 +1,12 @@ +import { Buffer } from "buffer" import z from "zod" +import { appendFile } from "fs/promises" +import fs from "fs/promises" import { spawn } from "child_process" import { Tool } from "./tool" import path from "path" import DESCRIPTION from "./bash.txt" -import { Log } from "../util/log" +import { Identifier } from "../id/id" import { Instance } from "../project/instance" import { lazy } from "@/util/lazy" import { Language } from "web-tree-sitter" @@ -17,7 +20,8 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncation" -const MAX_METADATA_LENGTH = 30_000 +import { Log } from "../util/log" + const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 export const log = Log.create({ service: "bash-tool" }) @@ -71,9 +75,12 @@ export const BashTool = Tool.define("bash", async () => { description: z .string() .describe( - "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", + "Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working directory status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'", ), }), + formatValidationError(error: any): string { + return error.errors.map((e: any) => `${e.path.join(".")}: ${e.message}`).join("\n") + }, async execute(params, ctx) { const cwd = params.workdir || Instance.directory if (params.timeout !== undefined && params.timeout < 0) { @@ -154,6 +161,18 @@ export const BashTool = Tool.define("bash", async () => { }) } + // Use Buffer for accumulation. When exceeding WINDOW_SIZE, + // switch to file streaming to avoid O(n²) memory growth. + const tmpId = Identifier.ascending("tool") + const tmpPath = path.join(Truncate.DIR, tmpId) + await fs.mkdir(Truncate.DIR, { recursive: true }) + const WINDOW_SIZE = 50 * 1024 + let output: Buffer | null = null + let outputSize = 0 + let lineCount = 0 + let truncated = false + let writer: ReturnType | null = null + const proc = spawn(params.command, { shell, cwd, @@ -164,9 +183,6 @@ export const BashTool = Tool.define("bash", async () => { detached: process.platform !== "win32", }) - let output = "" - - // Initialize metadata with empty output ctx.metadata({ metadata: { output: "", @@ -175,14 +191,37 @@ export const BashTool = Tool.define("bash", async () => { }) const append = (chunk: Buffer) => { - output += chunk.toString() - ctx.metadata({ - metadata: { - // truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access) - output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, - description: params.description, - }, - }) + const str = chunk.toString() + outputSize += str.length + lineCount += (str.match(/\n/g) || []).length + + if (lineCount > Truncate.MAX_LINES || outputSize > WINDOW_SIZE) { + truncated = true + if (!writer) { + const tmpFile = Bun.file(tmpPath) + writer = tmpFile.writer() + if (output) { + writer.write(output) + } + } + writer.write(chunk) + } else { + if (output) { + output = Buffer.concat([output, chunk]) + } else { + output = chunk + } + } + + if (output) { + const preview = output.toString() + ctx.metadata({ + metadata: { + output: preview, + description: params.description, + }, + }) + } } proc.stdout?.on("data", append) @@ -194,14 +233,23 @@ export const BashTool = Tool.define("bash", async () => { const kill = () => Shell.killTree(proc, { exited: () => exited }) + const cleanupFile = async () => { + if (!truncated) return + try { + await fs.unlink(tmpPath) + } catch {} + } + if (ctx.abort.aborted) { aborted = true await kill() + await cleanupFile() } const abortHandler = () => { aborted = true void kill() + void cleanupFile() } ctx.abort.addEventListener("abort", abortHandler, { once: true }) @@ -211,24 +259,33 @@ export const BashTool = Tool.define("bash", async () => { void kill() }, timeout + 100) - await new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timeoutTimer) - ctx.abort.removeEventListener("abort", abortHandler) - } + try { + await new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timeoutTimer) + ctx.abort.removeEventListener("abort", abortHandler) + } - proc.once("exit", () => { - exited = true - cleanup() - resolve() + proc.once("exit", () => { + exited = true + cleanup() + resolve() + }) + + proc.once("error", (error) => { + exited = true + cleanup() + reject(error) + }) }) + } catch (err) { + await cleanupFile() + throw err + } - proc.once("error", (error) => { - exited = true - cleanup() - reject(error) - }) - }) + if (truncated) { + await writer!.flush() + } const resultMetadata: string[] = [] @@ -240,18 +297,49 @@ export const BashTool = Tool.define("bash", async () => { resultMetadata.push("User aborted the command") } + if (truncated) { + if (resultMetadata.length > 0) { + const metadataText = "\n\n\n" + resultMetadata.join("\n") + "\n" + await appendFile(tmpPath, metadataText) + } + + const userPreview = output ? (output as Buffer).toString() : "" + const agentPreview = `${userPreview}\n\n...${outputSize - WINDOW_SIZE} bytes truncated...\n\nThe tool call succeeded but the output was truncated. Full output saved to: ${tmpPath}\nGrep to search the full content or Read with offset/limit to view specific sections.\nIf Task tool is available, delegate that to an explore agent.` + + return { + title: params.description, + metadata: { + output: userPreview, + truncated, + exit: proc.exitCode, + description: params.description, + outputPath: tmpPath, + }, + output: agentPreview, + } + } + if (resultMetadata.length > 0) { - output += "\n\n\n" + resultMetadata.join("\n") + "\n" + const metadataText = "\n\n\n" + resultMetadata.join("\n") + "\n" + if (output) { + output = Buffer.concat([output, Buffer.from(metadataText)]) + } else { + output = Buffer.from(metadataText) + } } + const outputStr = output ? (output as Buffer).toString() : "" + return { title: params.description, metadata: { - output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, + output: outputStr, + truncated, exit: proc.exitCode, description: params.description, + outputPath: "", }, - output, + output: outputStr, } }, } diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 78ab325af41..982493e6de4 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -6,6 +6,7 @@ import { Truncate } from "./truncation" export namespace Tool { interface Metadata { + truncated?: boolean [key: string]: any }