mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-21 05:10:58 +08:00
fix: bash memory usage (#22660)
This commit is contained in:
@@ -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"
|
||||
@@ -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) => {
|
||||
@@ -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) {
|
||||
@@ -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) {
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.`
|
||||
@@ -131,7 +136,7 @@ export namespace Truncate {
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return Service.of({ cleanup, output })
|
||||
return Service.of({ cleanup, write, output })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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+/)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -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+/)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user