Skip to content
Merged
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
121 changes: 116 additions & 5 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import z from "zod"
import os from "os"
import { createWriteStream } from "node:fs"
import { Tool } from "./tool"
import path from "path"
import DESCRIPTION from "./bash.txt"
Expand Down Expand Up @@ -76,6 +77,11 @@ type Scan = {
always: Set<string>
}

type Chunk = {
text: string
size: number
}

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

const resolveWasm = (asset: string) => {
Expand Down Expand Up @@ -211,7 +217,39 @@ function pathArgs(list: Part[], ps: boolean) {

function preview(text: string) {
if (text.length <= MAX_METADATA_LENGTH) return text
return text.slice(0, MAX_METADATA_LENGTH) + "\n\n..."
return "...\n\n" + text.slice(-MAX_METADATA_LENGTH)
}

function tail(text: string, maxLines: number, maxBytes: number) {
const lines = text.split("\n")
if (lines.length <= maxLines && Buffer.byteLength(text, "utf-8") <= maxBytes) {
return {
text,
cut: false,
}
}

const out: string[] = []
let bytes = 0
for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
if (out.length === 0) {
const buf = Buffer.from(lines[i], "utf-8")
let start = buf.length - maxBytes
if (start < 0) start = 0
while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++
out.unshift(buf.subarray(start).toString("utf-8"))
}
break
}
out.unshift(lines[i])
bytes += size
}
return {
text: out.join("\n"),
cut: true,
}
}

const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) {
Expand Down Expand Up @@ -295,6 +333,7 @@ export const BashTool = Tool.define(
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner
const fs = yield* AppFileSystem.Service
const trunc = yield* Truncate.Service
const plugin = yield* Plugin.Service

const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
Expand Down Expand Up @@ -381,7 +420,16 @@ export const BashTool = Tool.define(
},
ctx: Tool.Context,
) {
let output = ""
const bytes = Truncate.MAX_BYTES
const lines = Truncate.MAX_LINES
const keep = bytes * 2
let full = ""
let last = ""
const list: Chunk[] = []
let used = 0
let file = ""
let sink: ReturnType<typeof createWriteStream> | undefined
let cut = false
let expired = false
let aborted = false

Expand All @@ -398,10 +446,47 @@ export const BashTool = Tool.define(

yield* Effect.forkScoped(
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
output += chunk
const size = Buffer.byteLength(chunk, "utf-8")
list.push({ text: chunk, size })
used += size
while (used > keep && list.length > 1) {
const item = list.shift()
if (!item) break
used -= item.size
cut = true
}

last = preview(last + chunk)

if (file) {
sink?.write(chunk)
} else {
full += chunk
if (Buffer.byteLength(full, "utf-8") > bytes) {
return trunc.write(full).pipe(
Effect.andThen((next) =>
Effect.sync(() => {
file = next
cut = true
sink = createWriteStream(next, { flags: "a" })
full = ""
}),
),
Effect.andThen(
ctx.metadata({
metadata: {
output: last,
description: input.description,
},
}),
),
)
}
}

return ctx.metadata({
metadata: {
output: preview(output),
output: last,
description: input.description,
},
})
Expand Down Expand Up @@ -443,16 +528,42 @@ export const BashTool = Tool.define(
)
}
if (aborted) meta.push("User aborted the command")
const raw = list.map((item) => item.text).join("")
const end = tail(raw, lines, bytes)
if (end.cut) cut = true
if (!file && end.cut) {
file = yield* trunc.write(raw)
}

let output = end.text
if (!output) output = "(no output)"

if (cut && file) {
output = `...output truncated...\n\nFull output saved to: ${file}\n\n` + output
}

if (meta.length > 0) {
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
}
if (sink) {
const stream = sink
yield* Effect.promise(
() =>
new Promise<void>((resolve) => {
stream.end(() => resolve())
stream.on("error", () => resolve())
}),
)
}

return {
title: input.description,
metadata: {
output: preview(output),
output: last || preview(output),
exit: code,
description: input.description,
truncated: cut,
...(cut && file ? { outputPath: file } : {}),
},
output,
}
Expand Down
15 changes: 10 additions & 5 deletions packages/opencode/src/tool/truncate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export namespace Truncate {

export interface Interface {
readonly cleanup: () => Effect.Effect<void>
readonly write: (text: string) => Effect.Effect<string>
/**
* Returns output unchanged when it fits within the limits, otherwise writes the full text
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
Expand Down Expand Up @@ -61,6 +62,13 @@ export namespace Truncate {
}
})

const write = Effect.fn("Truncate.write")(function* (text: string) {
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
return file
})

const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
Expand Down Expand Up @@ -102,10 +110,7 @@ export namespace Truncate {
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "bytes" : "lines"
const preview = out.join("\n")
const file = path.join(TRUNCATION_DIR, ToolID.ascending())

yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
const file = yield* write(text)

const hint = hasTaskTool(agent)
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse the Task tool to have explore agent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.`
Expand All @@ -131,7 +136,7 @@ export namespace Truncate {
Effect.forkScoped,
)

return Service.of({ cleanup, output })
return Service.of({ cleanup, write, output })
}),
)

Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/test/session/prompt-effect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1362,8 +1362,8 @@ unix(

expect(tool.state.metadata.truncated).toBe(true)
expect(typeof tool.state.metadata.outputPath).toBe("string")
expect(tool.state.output).toContain("The tool call succeeded but the output was truncated.")
expect(tool.state.output).toContain("Full output saved to:")
expect(tool.state.output).toMatch(/\.\.\.output truncated\.\.\./)
expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/)
expect(tool.state.output).not.toContain("Tool execution aborted")
}),
{ git: true, config: providerCfg },
Expand Down
8 changes: 4 additions & 4 deletions packages/opencode/test/tool/bash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1116,8 +1116,8 @@ describe("tool.bash truncation", () => {
),
)
mustTruncate(result)
expect(result.output).toContain("truncated")
expect(result.output).toContain("The tool call succeeded but the output was truncated")
expect(result.output).toMatch(/\.\.\.output truncated\.\.\./)
expect(result.output).toMatch(/Full output saved to:\s+\S+/)
},
})
})
Expand All @@ -1138,8 +1138,8 @@ describe("tool.bash truncation", () => {
),
)
mustTruncate(result)
expect(result.output).toContain("truncated")
expect(result.output).toContain("The tool call succeeded but the output was truncated")
expect(result.output).toMatch(/\.\.\.output truncated\.\.\./)
expect(result.output).toMatch(/Full output saved to:\s+\S+/)
},
})
})
Expand Down
Loading