Skip to content

Commit 2e0f232

Browse files
cgwaltersroot
authored andcommitted
fix: stream large bash output to tmpfile to prevent O(n²) memory growth
Stream command output directly to a temp file when it exceeds threshold, avoiding memory bloat for commands with huge output. Adds output_filter param for regex-based line filtering. Original work from PR anomalyco#8953 by @cgwalters Co-authored-by: Colin Walters <walters@verbum.org>
1 parent 6efcd20 commit 2e0f232

File tree

8 files changed

+556
-55
lines changed

8 files changed

+556
-55
lines changed

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { Prompt, type PromptRef } from "@tui/component/prompt"
3232
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
3333
import { useLocal } from "@tui/context/local"
3434
import { Locale } from "@/util/locale"
35+
import { formatSize } from "@/util/format"
3536
import type { Tool } from "@/tool/tool"
3637
import type { ReadTool } from "@/tool/read"
3738
import type { WriteTool } from "@/tool/write"
@@ -1739,6 +1740,14 @@ function Bash(props: ToolProps<typeof BashTool>) {
17391740
return [...lines().slice(0, 10), "…"].join("\n")
17401741
})
17411742

1743+
const filterInfo = createMemo(() => {
1744+
if (!props.metadata.filtered) return undefined
1745+
const total = formatSize(props.metadata.totalBytes ?? 0)
1746+
const omitted = formatSize(props.metadata.omittedBytes ?? 0)
1747+
const matches = props.metadata.matchCount ?? 0
1748+
return `Filtered: ${matches} match${matches === 1 ? "" : "es"} from ${total} (${omitted} omitted)`
1749+
})
1750+
17421751
const workdirDisplay = createMemo(() => {
17431752
const workdir = props.input.workdir
17441753
if (!workdir || workdir === ".") return undefined
@@ -1778,6 +1787,9 @@ function Bash(props: ToolProps<typeof BashTool>) {
17781787
<Show when={output()}>
17791788
<text fg={theme.text}>{limited()}</text>
17801789
</Show>
1790+
<Show when={filterInfo()}>
1791+
<text fg={theme.textMuted}>{filterInfo()}</text>
1792+
</Show>
17811793
<Show when={overflow()}>
17821794
<text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
17831795
</Show>

packages/opencode/src/cli/cmd/uninstall.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { UI } from "../ui"
33
import * as prompts from "@clack/prompts"
44
import { Installation } from "../../installation"
55
import { Global } from "../../global"
6+
import { formatSize } from "../../util/format"
67
import { $ } from "bun"
78
import fs from "fs/promises"
89
import path from "path"
@@ -340,13 +341,6 @@ async function getDirectorySize(dir: string): Promise<number> {
340341
return total
341342
}
342343

343-
function formatSize(bytes: number): string {
344-
if (bytes < 1024) return `${bytes} B`
345-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
346-
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
347-
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`
348-
}
349-
350344
function shortenPath(p: string): string {
351345
const home = os.homedir()
352346
if (p.startsWith(home)) {

packages/opencode/src/session/prompt.ts

Lines changed: 67 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { ReadTool } from "../tool/read"
2929
import { FileTime } from "../file/time"
3030
import { Flag } from "../flag/flag"
3131
import { ulid } from "ulid"
32-
import { spawn } from "child_process"
32+
3333
import { Command } from "../command"
3434
import { $, fileURLToPath, pathToFileURL } from "bun"
3535
import { ConfigMarkdown } from "../config/markdown"
@@ -44,7 +44,8 @@ import { SessionStatus } from "./status"
4444
import { LLM } from "./llm"
4545
import { iife } from "@/util/iife"
4646
import { Shell } from "@/shell/shell"
47-
import { Truncate } from "@/tool/truncation"
47+
import { Truncate, StreamingOutput } from "@/tool/truncation"
48+
import { spawn } from "child_process"
4849

4950
// @ts-ignore
5051
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -1623,40 +1624,32 @@ NOTE: At any point in time through this workflow you should feel free to ask the
16231624
{ cwd, sessionID: input.sessionID, callID: part.callID },
16241625
{ env: {} },
16251626
)
1627+
const streaming = new StreamingOutput()
1628+
16261629
const proc = spawn(shell, args, {
16271630
cwd,
16281631
detached: process.platform !== "win32",
1629-
stdio: ["ignore", "pipe", "pipe"],
16301632
env: {
16311633
...process.env,
16321634
...shellEnv.env,
16331635
TERM: "dumb",
16341636
},
1637+
stdio: ["ignore", "pipe", "pipe"],
16351638
})
16361639

1637-
let output = ""
1638-
1639-
proc.stdout?.on("data", (chunk) => {
1640-
output += chunk.toString()
1640+
const append = (chunk: Buffer) => {
1641+
const preview = streaming.append(chunk)
16411642
if (part.state.status === "running") {
16421643
part.state.metadata = {
1643-
output: output,
1644+
output: preview,
16441645
description: "",
16451646
}
16461647
Session.updatePart(part)
16471648
}
1648-
})
1649+
}
16491650

1650-
proc.stderr?.on("data", (chunk) => {
1651-
output += chunk.toString()
1652-
if (part.state.status === "running") {
1653-
part.state.metadata = {
1654-
output: output,
1655-
description: "",
1656-
}
1657-
Session.updatePart(part)
1658-
}
1659-
})
1651+
proc.stdout?.on("data", append)
1652+
proc.stderr?.on("data", append)
16601653

16611654
let aborted = false
16621655
let exited = false
@@ -1675,33 +1668,72 @@ NOTE: At any point in time through this workflow you should feel free to ask the
16751668

16761669
abort.addEventListener("abort", abortHandler, { once: true })
16771670

1678-
await new Promise<void>((resolve) => {
1679-
proc.on("close", () => {
1680-
exited = true
1671+
await new Promise<void>((resolve, reject) => {
1672+
const cleanup = () => {
16811673
abort.removeEventListener("abort", abortHandler)
1674+
}
1675+
1676+
proc.once("exit", () => {
1677+
exited = true
1678+
cleanup()
1679+
resolve()
1680+
})
1681+
1682+
proc.once("error", (error) => {
1683+
exited = true
1684+
cleanup()
1685+
reject(error)
1686+
})
1687+
1688+
proc.once("close", () => {
1689+
exited = true
1690+
cleanup()
16821691
resolve()
16831692
})
16841693
})
16851694

1695+
streaming.close()
1696+
16861697
if (aborted) {
1687-
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
1698+
streaming.appendMetadata("\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n"))
16881699
}
1700+
16891701
msg.time.completed = Date.now()
16901702
await Session.updateMessage(msg)
1703+
16911704
if (part.state.status === "running") {
1692-
part.state = {
1693-
status: "completed",
1694-
time: {
1695-
...part.state.time,
1696-
end: Date.now(),
1697-
},
1698-
input: part.state.input,
1699-
title: "",
1700-
metadata: {
1705+
if (streaming.truncated) {
1706+
part.state = {
1707+
status: "completed",
1708+
time: {
1709+
...part.state.time,
1710+
end: Date.now(),
1711+
},
1712+
input: part.state.input,
1713+
title: "",
1714+
metadata: {
1715+
output: `[output streamed to file: ${streaming.totalBytes} bytes]`,
1716+
description: "",
1717+
outputPath: streaming.outputPath,
1718+
},
1719+
output: streaming.finalize(),
1720+
}
1721+
} else {
1722+
const output = streaming.inMemoryOutput
1723+
part.state = {
1724+
status: "completed",
1725+
time: {
1726+
...part.state.time,
1727+
end: Date.now(),
1728+
},
1729+
input: part.state.input,
1730+
title: "",
1731+
metadata: {
1732+
output,
1733+
description: "",
1734+
},
17011735
output,
1702-
description: "",
1703-
},
1704-
output,
1736+
}
17051737
}
17061738
await Session.updatePart(part)
17071739
}

packages/opencode/src/tool/bash.ts

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,33 @@ import { lazy } from "@/util/lazy"
99
import { Language } from "web-tree-sitter"
1010

1111
import { $ } from "bun"
12-
import { Filesystem } from "@/util/filesystem"
1312
import { fileURLToPath } from "url"
1413
import { Flag } from "@/flag/flag.ts"
1514
import { Shell } from "@/shell/shell"
15+
import { Filesystem } from "@/util/filesystem"
1616

1717
import { BashArity } from "@/permission/arity"
18-
import { Truncate } from "./truncation"
18+
import { Truncate, StreamingOutput } from "./truncation"
1919
import { Plugin } from "@/plugin"
2020

2121
const MAX_METADATA_LENGTH = 30_000
2222
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
2323

2424
export const log = Log.create({ service: "bash-tool" })
2525

26+
export interface BashMetadata {
27+
output: string
28+
exit: number | null
29+
description: string
30+
truncated?: boolean
31+
outputPath?: string
32+
filtered?: boolean
33+
filterPattern?: string
34+
matchCount?: number
35+
totalBytes?: number
36+
omittedBytes?: number
37+
}
38+
2639
const resolveWasm = (asset: string) => {
2740
if (asset.startsWith("file://")) return fileURLToPath(asset)
2841
if (asset.startsWith("/") || /^[a-z]:/i.test(asset)) return asset
@@ -69,6 +82,12 @@ export const BashTool = Tool.define("bash", async () => {
6982
`The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`,
7083
)
7184
.optional(),
85+
output_filter: z
86+
.string()
87+
.describe(
88+
`Optional regex pattern to filter output. When set, full output streams to a file while lines matching the pattern are returned inline. Useful for build commands where you only care about warnings/errors. Example: "^(warning|error|WARN|ERROR):.*" to capture compiler diagnostics. The regex is matched against each line.`,
89+
)
90+
.optional(),
7291
description: z
7392
.string()
7493
.describe(
@@ -81,6 +100,16 @@ export const BashTool = Tool.define("bash", async () => {
81100
throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
82101
}
83102
const timeout = params.timeout ?? DEFAULT_TIMEOUT
103+
104+
// Parse output_filter regex if provided
105+
let filter: RegExp | undefined
106+
if (params.output_filter) {
107+
try {
108+
filter = new RegExp(params.output_filter)
109+
} catch (e) {
110+
throw new Error(`Invalid output_filter regex: ${params.output_filter}. ${e}`)
111+
}
112+
}
84113
const tree = await parser().then((p) => p.parse(params.command))
85114
if (!tree) {
86115
throw new Error("Failed to parse command")
@@ -169,6 +198,8 @@ export const BashTool = Tool.define("bash", async () => {
169198
{ cwd, sessionID: ctx.sessionID, callID: ctx.callID },
170199
{ env: {} },
171200
)
201+
const streaming = new StreamingOutput({ filter })
202+
172203
const proc = spawn(params.command, {
173204
shell,
174205
cwd,
@@ -180,8 +211,6 @@ export const BashTool = Tool.define("bash", async () => {
180211
detached: process.platform !== "win32",
181212
})
182213

183-
let output = ""
184-
185214
// Initialize metadata with empty output
186215
ctx.metadata({
187216
metadata: {
@@ -191,11 +220,12 @@ export const BashTool = Tool.define("bash", async () => {
191220
})
192221

193222
const append = (chunk: Buffer) => {
194-
output += chunk.toString()
223+
const preview = streaming.append(chunk)
224+
const display =
225+
preview.length > MAX_METADATA_LENGTH ? preview.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : preview
195226
ctx.metadata({
196227
metadata: {
197-
// truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access)
198-
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
228+
output: display,
199229
description: params.description,
200230
},
201231
})
@@ -244,29 +274,75 @@ export const BashTool = Tool.define("bash", async () => {
244274
cleanup()
245275
reject(error)
246276
})
277+
278+
proc.once("close", () => {
279+
exited = true
280+
cleanup()
281+
resolve()
282+
})
247283
})
248284

249-
const resultMetadata: string[] = []
285+
streaming.close()
250286

287+
const resultMetadata: string[] = []
251288
if (timedOut) {
252289
resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`)
253290
}
254-
255291
if (aborted) {
256292
resultMetadata.push("User aborted the command")
257293
}
258-
259294
if (resultMetadata.length > 0) {
260-
output += "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>"
295+
streaming.appendMetadata("\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>")
261296
}
262297

298+
// If using filter, return filtered lines
299+
if (streaming.hasFilter) {
300+
const output = streaming.truncated
301+
? `${streaming.filteredOutput}\n${streaming.finalize(params.output_filter)}`
302+
: streaming.finalize(params.output_filter)
303+
304+
return {
305+
title: params.description,
306+
metadata: {
307+
output: streaming.filteredOutput || `[no matches for filter: ${params.output_filter}]`,
308+
exit: proc.exitCode,
309+
description: params.description,
310+
truncated: streaming.truncated,
311+
outputPath: streaming.outputPath,
312+
filtered: true,
313+
filterPattern: params.output_filter,
314+
matchCount: streaming.matchCount,
315+
totalBytes: streaming.totalBytes,
316+
omittedBytes: streaming.omittedBytes,
317+
} as BashMetadata,
318+
output,
319+
}
320+
}
321+
322+
// If we streamed to a file (threshold exceeded), return truncated result
323+
if (streaming.truncated) {
324+
return {
325+
title: params.description,
326+
metadata: {
327+
output: `[output streamed to file: ${streaming.totalBytes} bytes]`,
328+
exit: proc.exitCode,
329+
description: params.description,
330+
truncated: true,
331+
outputPath: streaming.outputPath,
332+
totalBytes: streaming.totalBytes,
333+
} as BashMetadata,
334+
output: streaming.finalize(),
335+
}
336+
}
337+
338+
const output = streaming.inMemoryOutput
263339
return {
264340
title: params.description,
265341
metadata: {
266342
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
267343
exit: proc.exitCode,
268344
description: params.description,
269-
},
345+
} as BashMetadata,
270346
output,
271347
}
272348
},

0 commit comments

Comments
 (0)