Skip to content

Commit 0b1b21a

Browse files
committed
refactor: extract shared Output utility for command streaming
Both bash.ts (BashTool) and prompt.ts (shell command execution) had duplicated logic for accumulating command output that caused O(n²) memory usage from repeated string concatenation. Extract a shared Output utility that: - Accumulates output in memory up to a threshold (50KB) - Streams to a temp file when threshold is exceeded - Provides preview for UI display while streaming - Handles cleanup on abort This prep commit deduplicates the streaming logic so both code paths benefit from the fix and future improvements only need to be made once. Assisted-by: OpenCode (Claude claude-sonnet-4-20250514)
1 parent 3b7c347 commit 0b1b21a

4 files changed

Lines changed: 200 additions & 29 deletions

File tree

packages/opencode/src/session/prompt.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { LLM } from "./llm"
4545
import { iife } from "@/util/iife"
4646
import { Shell } from "@/shell/shell"
4747
import { Truncate } from "@/tool/truncation"
48+
import { Output } from "@/util/output"
4849

4950
// @ts-ignore
5051
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -1485,29 +1486,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the
14851486
},
14861487
})
14871488

1488-
let output = ""
1489+
const state = Output.create()
14891490

1490-
proc.stdout?.on("data", (chunk) => {
1491-
output += chunk.toString()
1491+
const handleChunk = (chunk: Buffer) => {
1492+
Output.append(state, chunk)
14921493
if (part.state.status === "running") {
14931494
part.state.metadata = {
1494-
output: output,
1495+
output: Output.preview(state),
14951496
description: "",
14961497
}
14971498
Session.updatePart(part)
14981499
}
1499-
})
1500+
}
15001501

1501-
proc.stderr?.on("data", (chunk) => {
1502-
output += chunk.toString()
1503-
if (part.state.status === "running") {
1504-
part.state.metadata = {
1505-
output: output,
1506-
description: "",
1507-
}
1508-
Session.updatePart(part)
1509-
}
1510-
})
1502+
proc.stdout?.on("data", handleChunk)
1503+
proc.stderr?.on("data", handleChunk)
15111504

15121505
let aborted = false
15131506
let exited = false
@@ -1517,11 +1510,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
15171510
if (abort.aborted) {
15181511
aborted = true
15191512
await kill()
1513+
Output.cleanup(state)
15201514
}
15211515

15221516
const abortHandler = () => {
15231517
aborted = true
15241518
void kill()
1519+
Output.cleanup(state)
15251520
}
15261521

15271522
abort.addEventListener("abort", abortHandler, { once: true })
@@ -1534,11 +1529,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the
15341529
})
15351530
})
15361531

1532+
Output.close(state)
1533+
15371534
if (aborted) {
1538-
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
1535+
Output.appendMetadata(state, "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n"))
15391536
}
1537+
15401538
msg.time.completed = Date.now()
15411539
await Session.updateMessage(msg)
1540+
1541+
const result = Output.finalize(state)
1542+
15421543
if (part.state.status === "running") {
15431544
part.state = {
15441545
status: "completed",
@@ -1549,10 +1550,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
15491550
input: part.state.input,
15501551
title: "",
15511552
metadata: {
1552-
output,
1553+
output: result.preview,
15531554
description: "",
1555+
...(result.truncated && { outputPath: result.path }),
15541556
},
1555-
output,
1557+
output: result.output,
15561558
}
15571559
await Session.updatePart(part)
15581560
}

packages/opencode/src/tool/bash.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@ 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"
1615

1716
import { BashArity } from "@/permission/arity"
1817
import { Truncate } from "./truncation"
18+
import { Output } from "@/util/output"
1919

20-
const MAX_METADATA_LENGTH = 30_000
2120
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
2221

2322
export const log = Log.create({ service: "bash-tool" })
@@ -164,7 +163,7 @@ export const BashTool = Tool.define("bash", async () => {
164163
detached: process.platform !== "win32",
165164
})
166165

167-
let output = ""
166+
const state = Output.create()
168167

169168
// Initialize metadata with empty output
170169
ctx.metadata({
@@ -175,11 +174,10 @@ export const BashTool = Tool.define("bash", async () => {
175174
})
176175

177176
const append = (chunk: Buffer) => {
178-
output += chunk.toString()
177+
Output.append(state, chunk)
179178
ctx.metadata({
180179
metadata: {
181-
// truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access)
182-
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
180+
output: Output.preview(state),
183181
description: params.description,
184182
},
185183
})
@@ -197,11 +195,13 @@ export const BashTool = Tool.define("bash", async () => {
197195
if (ctx.abort.aborted) {
198196
aborted = true
199197
await kill()
198+
Output.cleanup(state)
200199
}
201200

202201
const abortHandler = () => {
203202
aborted = true
204203
void kill()
204+
Output.cleanup(state)
205205
}
206206

207207
ctx.abort.addEventListener("abort", abortHandler, { once: true })
@@ -240,18 +240,28 @@ export const BashTool = Tool.define("bash", async () => {
240240
resultMetadata.push("User aborted the command")
241241
}
242242

243+
Output.close(state)
244+
243245
if (resultMetadata.length > 0) {
244-
output += "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>"
246+
Output.appendMetadata(state, "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>")
245247
}
246248

249+
const result = Output.finalize(state, {
250+
hint: state.file
251+
? `The command output was ${state.written} bytes and was truncated (inline limit: ${Output.THRESHOLD} bytes).\nFull output saved to: ${state.file.path}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
252+
: undefined,
253+
})
254+
247255
return {
248256
title: params.description,
249257
metadata: {
250-
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
258+
output: result.preview,
251259
exit: proc.exitCode,
252260
description: params.description,
261+
truncated: result.truncated,
262+
outputPath: result.path,
253263
},
254-
output,
264+
output: result.output,
255265
}
256266
},
257267
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import fs from "fs"
2+
import path from "path"
3+
import { Identifier } from "../id/id"
4+
import { Truncate } from "../tool/truncation"
5+
import { Log } from "./log"
6+
7+
const log = Log.create({ service: "output" })
8+
9+
/**
10+
* Handles streaming command output with automatic file spillover when threshold is exceeded.
11+
* Avoids O(n²) memory usage from repeated string concatenation by:
12+
* 1. Accumulating in memory up to a threshold
13+
* 2. Streaming to a temp file once threshold is exceeded
14+
*/
15+
export namespace Output {
16+
export const THRESHOLD = Truncate.MAX_BYTES
17+
export const MAX_PREVIEW = 30_000
18+
19+
export interface StreamFile {
20+
fd: number
21+
path: string
22+
}
23+
24+
export interface State {
25+
/** In-memory buffer (only used when not streaming to file) */
26+
buffer: string
27+
/** Total bytes received */
28+
bytes: number
29+
/** Total lines received */
30+
lines: number
31+
/** File handle when streaming to disk */
32+
file?: StreamFile
33+
/** Bytes written to file */
34+
written: number
35+
}
36+
37+
export interface Result {
38+
/** The output content (full if not truncated, preview + hint if truncated) */
39+
output: string
40+
/** Preview for UI display (always fits in memory) */
41+
preview: string
42+
/** Whether output was truncated/streamed to file */
43+
truncated: boolean
44+
/** Path to file if output was streamed */
45+
path?: string
46+
/** Total bytes of output */
47+
bytes: number
48+
/** Total lines of output */
49+
lines: number
50+
}
51+
52+
export function create(): State {
53+
return {
54+
buffer: "",
55+
bytes: 0,
56+
lines: 0,
57+
written: 0,
58+
}
59+
}
60+
61+
function createFile(state: State): StreamFile | undefined {
62+
let fd: number | undefined
63+
try {
64+
const dir = Truncate.DIR
65+
fs.mkdirSync(dir, { recursive: true })
66+
Truncate.cleanup().catch(() => {})
67+
const filepath = path.join(dir, Identifier.ascending("tool"))
68+
fd = fs.openSync(filepath, "w")
69+
if (state.buffer) {
70+
fs.writeSync(fd, state.buffer)
71+
state.written += Buffer.byteLength(state.buffer, "utf-8")
72+
}
73+
state.buffer = ""
74+
return { fd, path: filepath }
75+
} catch (e) {
76+
if (fd !== undefined) fs.closeSync(fd)
77+
log.warn("failed to create stream file, continuing in memory", { error: e })
78+
return undefined
79+
}
80+
}
81+
82+
export function append(state: State, chunk: Buffer | string): void {
83+
const text = typeof chunk === "string" ? chunk : chunk.toString()
84+
const size = typeof chunk === "string" ? Buffer.byteLength(chunk, "utf-8") : chunk.length
85+
86+
state.bytes += size
87+
state.lines += (text.match(/\n/g) || []).length
88+
89+
if (!state.file && (state.bytes > THRESHOLD || state.lines > Truncate.MAX_LINES)) {
90+
state.file = createFile(state)
91+
}
92+
93+
if (state.file) {
94+
fs.writeSync(state.file.fd, text)
95+
state.written += Buffer.byteLength(text, "utf-8")
96+
} else {
97+
state.buffer += text
98+
}
99+
}
100+
101+
export function preview(state: State): string {
102+
if (state.file) {
103+
return `[streaming to file: ${state.written} bytes written...]\n`
104+
}
105+
if (state.buffer.length > MAX_PREVIEW) {
106+
return state.buffer.slice(0, MAX_PREVIEW) + "\n\n..."
107+
}
108+
return state.buffer
109+
}
110+
111+
export function close(state: State): void {
112+
if (state.file) {
113+
fs.closeSync(state.file.fd)
114+
}
115+
}
116+
117+
export function cleanup(state: State): void {
118+
if (state.file) {
119+
try {
120+
fs.unlinkSync(state.file.path)
121+
} catch {}
122+
}
123+
}
124+
125+
export function appendMetadata(state: State, metadata: string): void {
126+
if (state.file) {
127+
fs.appendFileSync(state.file.path, metadata)
128+
} else {
129+
state.buffer += metadata
130+
}
131+
}
132+
133+
export function finalize(state: State, options?: { hint?: string }): Result {
134+
const truncated = !!state.file
135+
const filepath = state.file?.path
136+
137+
if (truncated && filepath) {
138+
const hint =
139+
options?.hint ??
140+
`The command output was ${state.written} bytes and was truncated (inline limit: ${THRESHOLD} bytes).\nFull output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.`
141+
return {
142+
output: hint,
143+
preview: `[output streamed to file: ${state.written} bytes]`,
144+
truncated: true,
145+
path: filepath,
146+
bytes: state.bytes,
147+
lines: state.lines,
148+
}
149+
}
150+
151+
return {
152+
output: state.buffer,
153+
preview: state.buffer.length > MAX_PREVIEW ? state.buffer.slice(0, MAX_PREVIEW) + "\n\n..." : state.buffer,
154+
truncated: false,
155+
bytes: state.bytes,
156+
lines: state.lines,
157+
}
158+
}
159+
}

packages/opencode/test/tool/bash.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ describe("tool.bash truncation", () => {
248248
)
249249
expect((result.metadata as any).truncated).toBe(true)
250250
expect(result.output).toContain("truncated")
251-
expect(result.output).toContain("The tool call succeeded but the output was truncated")
251+
expect(result.output).toContain("Full output saved to:")
252252
},
253253
})
254254
})
@@ -268,7 +268,7 @@ describe("tool.bash truncation", () => {
268268
)
269269
expect((result.metadata as any).truncated).toBe(true)
270270
expect(result.output).toContain("truncated")
271-
expect(result.output).toContain("The tool call succeeded but the output was truncated")
271+
expect(result.output).toContain("Full output saved to:")
272272
},
273273
})
274274
})

0 commit comments

Comments
 (0)