fix: bash memory usage (#22660)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
|
import { createWriteStream } from "node:fs"
|
||||||
import { Tool } from "./tool"
|
import { Tool } from "./tool"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import DESCRIPTION from "./bash.txt"
|
import DESCRIPTION from "./bash.txt"
|
||||||
@@ -76,6 +77,11 @@ type Scan = {
|
|||||||
always: Set<string>
|
always: Set<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Chunk = {
|
||||||
|
text: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
export const log = Log.create({ service: "bash-tool" })
|
export const log = Log.create({ service: "bash-tool" })
|
||||||
|
|
||||||
const resolveWasm = (asset: string) => {
|
const resolveWasm = (asset: string) => {
|
||||||
@@ -211,7 +217,39 @@ function pathArgs(list: Part[], ps: boolean) {
|
|||||||
|
|
||||||
function preview(text: string) {
|
function preview(text: string) {
|
||||||
if (text.length <= MAX_METADATA_LENGTH) return text
|
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) {
|
const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolean) {
|
||||||
@@ -295,6 +333,7 @@ export const BashTool = Tool.define(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const spawner = yield* ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner
|
||||||
const fs = yield* AppFileSystem.Service
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const trunc = yield* Truncate.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
|
|
||||||
const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
|
const cygpath = Effect.fn("BashTool.cygpath")(function* (shell: string, text: string) {
|
||||||
@@ -381,7 +420,16 @@ export const BashTool = Tool.define(
|
|||||||
},
|
},
|
||||||
ctx: Tool.Context,
|
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 expired = false
|
||||||
let aborted = false
|
let aborted = false
|
||||||
|
|
||||||
@@ -398,10 +446,47 @@ export const BashTool = Tool.define(
|
|||||||
|
|
||||||
yield* Effect.forkScoped(
|
yield* Effect.forkScoped(
|
||||||
Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
|
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({
|
return ctx.metadata({
|
||||||
metadata: {
|
metadata: {
|
||||||
output: preview(output),
|
output: last,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -443,16 +528,42 @@ export const BashTool = Tool.define(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (aborted) meta.push("User aborted the command")
|
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) {
|
if (meta.length > 0) {
|
||||||
output += "\n\n<bash_metadata>\n" + meta.join("\n") + "\n</bash_metadata>"
|
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 {
|
return {
|
||||||
title: input.description,
|
title: input.description,
|
||||||
metadata: {
|
metadata: {
|
||||||
output: preview(output),
|
output: last || preview(output),
|
||||||
exit: code,
|
exit: code,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
|
truncated: cut,
|
||||||
|
...(cut && file ? { outputPath: file } : {}),
|
||||||
},
|
},
|
||||||
output,
|
output,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export namespace Truncate {
|
|||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly cleanup: () => Effect.Effect<void>
|
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
|
* 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.
|
* 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 output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
|
||||||
const maxLines = options.maxLines ?? MAX_LINES
|
const maxLines = options.maxLines ?? MAX_LINES
|
||||||
const maxBytes = options.maxBytes ?? MAX_BYTES
|
const maxBytes = options.maxBytes ?? MAX_BYTES
|
||||||
@@ -102,10 +110,7 @@ export namespace Truncate {
|
|||||||
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
|
||||||
const unit = hitBytes ? "bytes" : "lines"
|
const unit = hitBytes ? "bytes" : "lines"
|
||||||
const preview = out.join("\n")
|
const preview = out.join("\n")
|
||||||
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
|
const file = yield* write(text)
|
||||||
|
|
||||||
yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
|
|
||||||
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
|
|
||||||
|
|
||||||
const hint = hasTaskTool(agent)
|
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.`
|
? `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,
|
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(tool.state.metadata.truncated).toBe(true)
|
||||||
expect(typeof tool.state.metadata.outputPath).toBe("string")
|
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).toMatch(/\.\.\.output truncated\.\.\./)
|
||||||
expect(tool.state.output).toContain("Full output saved to:")
|
expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/)
|
||||||
expect(tool.state.output).not.toContain("Tool execution aborted")
|
expect(tool.state.output).not.toContain("Tool execution aborted")
|
||||||
}),
|
}),
|
||||||
{ git: true, config: providerCfg },
|
{ git: true, config: providerCfg },
|
||||||
|
|||||||
@@ -1116,8 +1116,8 @@ describe("tool.bash truncation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
mustTruncate(result)
|
mustTruncate(result)
|
||||||
expect(result.output).toContain("truncated")
|
expect(result.output).toMatch(/\.\.\.output truncated\.\.\./)
|
||||||
expect(result.output).toContain("The tool call succeeded but the output was truncated")
|
expect(result.output).toMatch(/Full output saved to:\s+\S+/)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1138,8 +1138,8 @@ describe("tool.bash truncation", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
mustTruncate(result)
|
mustTruncate(result)
|
||||||
expect(result.output).toContain("truncated")
|
expect(result.output).toMatch(/\.\.\.output truncated\.\.\./)
|
||||||
expect(result.output).toContain("The tool call succeeded but the output was truncated")
|
expect(result.output).toMatch(/Full output saved to:\s+\S+/)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user