fix: bash memory usage (#22660)

This commit is contained in:
Aiden Cline
2026-04-15 20:09:06 -05:00
committed by GitHub
parent 074ef032ee
commit 307251bf3c
4 changed files with 132 additions and 16 deletions

View File

@@ -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,
}

View File

@@ -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 })
}),
)

View File

@@ -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 },

View File

@@ -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+/)
},
})
})