Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 20 additions & 18 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation"
import { Output } from "@/util/output"

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

let output = ""
const state = Output.create()

proc.stdout?.on("data", (chunk) => {
output += chunk.toString()
const handleChunk = (chunk: Buffer) => {
Output.append(state, chunk)
if (part.state.status === "running") {
part.state.metadata = {
output: output,
output: Output.preview(state),
description: "",
}
Session.updatePart(part)
}
})
}

proc.stderr?.on("data", (chunk) => {
output += chunk.toString()
if (part.state.status === "running") {
part.state.metadata = {
output: output,
description: "",
}
Session.updatePart(part)
}
})
proc.stdout?.on("data", handleChunk)
proc.stderr?.on("data", handleChunk)

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

const abortHandler = () => {
aborted = true
void kill()
Output.cleanup(state)
}

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

Output.close(state)

if (aborted) {
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
Output.appendMetadata(state, "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n"))
}

msg.time.completed = Date.now()
await Session.updateMessage(msg)

const result = Output.finalize(state)

if (part.state.status === "running") {
part.state = {
status: "completed",
Expand All @@ -1549,10 +1550,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
input: part.state.input,
title: "",
metadata: {
output,
output: result.preview,
description: "",
...(result.truncated && { outputPath: result.path }),
},
output,
output: result.output,
}
await Session.updatePart(part)
}
Expand Down
28 changes: 19 additions & 9 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@ import { lazy } from "@/util/lazy"
import { Language } from "web-tree-sitter"

import { $ } from "bun"
import { Filesystem } from "@/util/filesystem"
import { fileURLToPath } from "url"
import { Flag } from "@/flag/flag.ts"
import { Shell } from "@/shell/shell"

import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncation"
import { Output } from "@/util/output"

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

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

let output = ""
const state = Output.create()

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

const append = (chunk: Buffer) => {
output += chunk.toString()
Output.append(state, chunk)
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,
output: Output.preview(state),
description: params.description,
},
})
Expand All @@ -197,11 +195,13 @@ export const BashTool = Tool.define("bash", async () => {
if (ctx.abort.aborted) {
aborted = true
await kill()
Output.cleanup(state)
}

const abortHandler = () => {
aborted = true
void kill()
Output.cleanup(state)
}

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

Output.close(state)

if (resultMetadata.length > 0) {
output += "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>"
Output.appendMetadata(state, "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>")
}

const result = Output.finalize(state, {
hint: state.file
? `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.`
: undefined,
})

return {
title: params.description,
metadata: {
output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output,
output: result.preview,
exit: proc.exitCode,
description: params.description,
truncated: result.truncated,
outputPath: result.path,
},
output,
output: result.output,
}
},
}
Expand Down
159 changes: 159 additions & 0 deletions packages/opencode/src/util/output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import fs from "fs"
import path from "path"
import { Identifier } from "../id/id"
import { Truncate } from "../tool/truncation"
import { Log } from "./log"

const log = Log.create({ service: "output" })

/**
* Handles streaming command output with automatic file spillover when threshold is exceeded.
* Avoids O(n²) memory usage from repeated string concatenation by:
* 1. Accumulating in memory up to a threshold
* 2. Streaming to a temp file once threshold is exceeded
*/
export namespace Output {
export const THRESHOLD = Truncate.MAX_BYTES
export const MAX_PREVIEW = 30_000

export interface StreamFile {
fd: number
path: string
}

export interface State {
/** In-memory buffer (only used when not streaming to file) */
buffer: string
/** Total bytes received */
bytes: number
/** Total lines received */
lines: number
/** File handle when streaming to disk */
file?: StreamFile
/** Bytes written to file */
written: number
}

export interface Result {
/** The output content (full if not truncated, preview + hint if truncated) */
output: string
/** Preview for UI display (always fits in memory) */
preview: string
/** Whether output was truncated/streamed to file */
truncated: boolean
/** Path to file if output was streamed */
path?: string
/** Total bytes of output */
bytes: number
/** Total lines of output */
lines: number
}

export function create(): State {
return {
buffer: "",
bytes: 0,
lines: 0,
written: 0,
}
}

function createFile(state: State): StreamFile | undefined {
let fd: number | undefined
try {
const dir = Truncate.DIR
fs.mkdirSync(dir, { recursive: true })
Truncate.cleanup().catch(() => {})
const filepath = path.join(dir, Identifier.ascending("tool"))
fd = fs.openSync(filepath, "w")
if (state.buffer) {
fs.writeSync(fd, state.buffer)
state.written += Buffer.byteLength(state.buffer, "utf-8")
}
state.buffer = ""
return { fd, path: filepath }
} catch (e) {
if (fd !== undefined) fs.closeSync(fd)
log.warn("failed to create stream file, continuing in memory", { error: e })
return undefined
}
}

export function append(state: State, chunk: Buffer | string): void {
const text = typeof chunk === "string" ? chunk : chunk.toString()
const size = typeof chunk === "string" ? Buffer.byteLength(chunk, "utf-8") : chunk.length

state.bytes += size
state.lines += (text.match(/\n/g) || []).length

if (!state.file && (state.bytes > THRESHOLD || state.lines > Truncate.MAX_LINES)) {
state.file = createFile(state)
}

if (state.file) {
fs.writeSync(state.file.fd, text)
state.written += Buffer.byteLength(text, "utf-8")
} else {
state.buffer += text
}
}

export function preview(state: State): string {
if (state.file) {
return `[streaming to file: ${state.written} bytes written...]\n`
}
if (state.buffer.length > MAX_PREVIEW) {
return state.buffer.slice(0, MAX_PREVIEW) + "\n\n..."
}
return state.buffer
}

export function close(state: State): void {
if (state.file) {
fs.closeSync(state.file.fd)
}
}

export function cleanup(state: State): void {
if (state.file) {
try {
fs.unlinkSync(state.file.path)
} catch {}
}
}

export function appendMetadata(state: State, metadata: string): void {
if (state.file) {
fs.appendFileSync(state.file.path, metadata)
} else {
state.buffer += metadata
}
}

export function finalize(state: State, options?: { hint?: string }): Result {
const truncated = !!state.file
const filepath = state.file?.path

if (truncated && filepath) {
const hint =
options?.hint ??
`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.`
return {
output: hint,
preview: `[output streamed to file: ${state.written} bytes]`,
truncated: true,
path: filepath,
bytes: state.bytes,
lines: state.lines,
}
}

return {
output: state.buffer,
preview: state.buffer.length > MAX_PREVIEW ? state.buffer.slice(0, MAX_PREVIEW) + "\n\n..." : state.buffer,
truncated: false,
bytes: state.bytes,
lines: state.lines,
}
}
}
4 changes: 2 additions & 2 deletions packages/opencode/test/tool/bash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ describe("tool.bash truncation", () => {
)
expect((result.metadata as any).truncated).toBe(true)
expect(result.output).toContain("truncated")
expect(result.output).toContain("The tool call succeeded but the output was truncated")
expect(result.output).toContain("Full output saved to:")
},
})
})
Expand All @@ -268,7 +268,7 @@ describe("tool.bash truncation", () => {
)
expect((result.metadata as any).truncated).toBe(true)
expect(result.output).toContain("truncated")
expect(result.output).toContain("The tool call succeeded but the output was truncated")
expect(result.output).toContain("Full output saved to:")
},
})
})
Expand Down