From 6c39f13cbdd6a2e07c35b6de75bbadb88d09408f Mon Sep 17 00:00:00 2001 From: Gustavo Noronha Silva Date: Tue, 20 Jan 2026 08:08:03 -0300 Subject: [PATCH] fix: reduce memory usage in command output (#9699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bash tool and the inline command execution were accumulating command output using `output += chunk.toString()`, which creates a new string for every chunk and copies all previous content. For commands producing large output (like meson in weston), this caused catastrophic O(n²) memory usage, as the GC had not time to catch up. Large output was eventually written to disk and truncated in memory, but that was only done at the end, potentially leading to running out of memory. Fix by streaming output to disk when it exceeds 50KB threshold: - Output < 50KB: stays in memory (no file created) - Output ≥ 50KB: streams to file, 50KB sliding window in memory Also adds line count tracking to match original Truncate.MAX_LINES behavior and proper cleanup on error/abort. The session/prompt.ts InteractiveCommand uses the same pattern. --- packages/opencode/src/session/prompt.ts | 132 ++++++++++++++------ packages/opencode/src/tool/bash.ts | 152 +++++++++++++++++++----- packages/opencode/src/tool/tool.ts | 1 + 3 files changed, 219 insertions(+), 66 deletions(-) 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 }