feat(truncate): allow configuring tool output truncation limits (#23770)

Co-authored-by: rgs_ramp <rgs@ramp.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
This commit is contained in:
rahul
2026-04-23 17:43:33 -04:00
committed by GitHub
co-authored by rgs_ramp Aiden Cline
parent e50a688ca3
commit f8c6ddd4cb
4 changed files with 106 additions and 12 deletions
+13
View File
@@ -201,6 +201,19 @@ export const Info = Schema.Struct({
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }), url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
}), }),
), ),
tool_output: Schema.optional(
Schema.Struct({
max_lines: Schema.optional(PositiveInt).annotate({
description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)",
}),
max_bytes: Schema.optional(PositiveInt).annotate({
description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",
}),
}),
).annotate({
description:
"Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",
}),
compaction: Schema.optional( compaction: Schema.optional(
Schema.Struct({ Schema.Struct({
auto: Schema.optional(Schema.Boolean).annotate({ auto: Schema.optional(Schema.Boolean).annotate({
+9 -8
View File
@@ -416,9 +416,8 @@ export const BashTool = Tool.define(
}, },
ctx: Tool.Context, ctx: Tool.Context,
) { ) {
const bytes = Truncate.MAX_BYTES const limits = yield* trunc.limits()
const lines = Truncate.MAX_LINES const keep = limits.maxBytes * 2
const keep = bytes * 2
let full = "" let full = ""
let last = "" let last = ""
const list: Chunk[] = [] const list: Chunk[] = []
@@ -458,7 +457,7 @@ export const BashTool = Tool.define(
sink?.write(chunk) sink?.write(chunk)
} else { } else {
full += chunk full += chunk
if (Buffer.byteLength(full, "utf-8") > bytes) { if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) {
return trunc.write(full).pipe( return trunc.write(full).pipe(
Effect.andThen((next) => Effect.andThen((next) =>
Effect.sync(() => { Effect.sync(() => {
@@ -525,7 +524,7 @@ 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 raw = list.map((item) => item.text).join("")
const end = tail(raw, lines, bytes) const end = tail(raw, limits.maxLines, limits.maxBytes)
if (end.cut) cut = true if (end.cut) cut = true
if (!file && end.cut) { if (!file && end.cut) {
file = yield* trunc.write(raw) file = yield* trunc.write(raw)
@@ -566,7 +565,7 @@ export const BashTool = Tool.define(
}) })
return () => return () =>
Effect.sync(() => { Effect.gen(function* () {
const shell = Shell.acceptable() const shell = Shell.acceptable()
const name = Shell.name(shell) const name = Shell.name(shell)
const chain = const chain =
@@ -575,13 +574,15 @@ export const BashTool = Tool.define(
: "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead." : "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
log.info("bash tool using shell", { shell }) log.info("bash tool using shell", { shell })
const limits = yield* trunc.limits()
return { return {
description: DESCRIPTION.replaceAll("${directory}", Instance.directory) description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
.replaceAll("${os}", process.platform) .replaceAll("${os}", process.platform)
.replaceAll("${shell}", name) .replaceAll("${shell}", name)
.replaceAll("${chaining}", chain) .replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES)) .replaceAll("${maxLines}", String(limits.maxLines))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), .replaceAll("${maxBytes}", String(limits.maxBytes)),
parameters: Parameters, parameters: Parameters,
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) => execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
Effect.gen(function* () { Effect.gen(function* () {
+20 -4
View File
@@ -1,9 +1,10 @@
import { NodePath } from "@effect/platform-node" import { NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect" import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect"
import path from "path" import path from "path"
import type { Agent } from "../agent/agent" import type { Agent } from "../agent/agent"
import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { evaluate } from "@/permission/evaluate" import { evaluate } from "@/permission/evaluate"
import { Config } from "../config"
import { Identifier } from "../id/id" import { Identifier } from "../id/id"
import { Log } from "../util" import { Log } from "../util"
import { ToolID } from "./schema" import { ToolID } from "./schema"
@@ -38,6 +39,10 @@ export interface Interface {
* 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.
*/ */
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result> readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
/**
* Resolved truncation limits: values from `tool_output` in opencode config, or MAX_LINES / MAX_BYTES if unset.
*/
readonly limits: () => Effect.Effect<{ maxLines: number; maxBytes: number }>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Truncate") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Truncate") {}
@@ -68,9 +73,20 @@ export const layer = Layer.effect(
return file return file
}) })
const limits = Effect.fn("Truncate.limits")(function* () {
const configSvc = yield* Effect.serviceOption(Config.Service)
if (Option.isNone(configSvc)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
const cfg = yield* configSvc.value.get().pipe(Effect.catch(() => Effect.succeed(undefined)))
return {
maxLines: cfg?.tool_output?.max_lines ?? MAX_LINES,
maxBytes: cfg?.tool_output?.max_bytes ?? MAX_BYTES,
}
})
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 resolved = yield* limits()
const maxBytes = options.maxBytes ?? MAX_BYTES const maxLines = options.maxLines ?? resolved.maxLines
const maxBytes = options.maxBytes ?? resolved.maxBytes
const direction = options.direction ?? "head" const direction = options.direction ?? "head"
const lines = text.split("\n") const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8") const totalBytes = Buffer.byteLength(text, "utf-8")
@@ -135,7 +151,7 @@ export const layer = Layer.effect(
Effect.forkScoped, Effect.forkScoped,
) )
return Service.of({ cleanup, write, output }) return Service.of({ cleanup, write, output, limits })
}), }),
) )
@@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { Effect, FileSystem, Layer } from "effect" import { Effect, FileSystem, Layer } from "effect"
import { Truncate } from "../../src/tool" import { Truncate } from "../../src/tool"
import { Config } from "../../src/config"
import { Identifier } from "../../src/id/id" import { Identifier } from "../../src/id/id"
import { Process } from "../../src/util" import { Process } from "../../src/util"
import { Filesystem } from "../../src/util" import { Filesystem } from "../../src/util"
@@ -14,6 +15,14 @@ const ROOT = path.resolve(import.meta.dir, "..", "..")
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer)) const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer))
const configuredLayer = (cfg: Config.Info) =>
Layer.mergeAll(
Truncate.defaultLayer,
NodeFileSystem.layer,
Layer.mock(Config.Service)({ get: () => Effect.succeed(cfg) }),
)
const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg))
describe("Truncate", () => { describe("Truncate", () => {
describe("output", () => { describe("output", () => {
it.live("truncates large json file by bytes", () => it.live("truncates large json file by bytes", () =>
@@ -94,6 +103,61 @@ describe("Truncate", () => {
expect(Truncate.MAX_BYTES).toBe(50 * 1024) expect(Truncate.MAX_BYTES).toBe(50 * 1024)
}) })
it.live("limits() falls back to MAX_LINES/MAX_BYTES when Config is not provided", () =>
Effect.gen(function* () {
const svc = yield* Truncate.Service
const resolved = yield* svc.limits()
expect(resolved.maxLines).toBe(Truncate.MAX_LINES)
expect(resolved.maxBytes).toBe(Truncate.MAX_BYTES)
}),
)
describe("with tool_output config", () => {
const limitsIt = configuredIt({ tool_output: { max_lines: 123, max_bytes: 456 } })
limitsIt.live("limits() reflects config overrides", () =>
Effect.gen(function* () {
const resolved = yield* (yield* Truncate.Service).limits()
expect(resolved.maxLines).toBe(123)
expect(resolved.maxBytes).toBe(456)
}),
)
// Huge byte budget isolates line truncation. 100 lines against max_lines: 10
// proves the configured line limit is what `output()` enforces.
const lineIt = configuredIt({ tool_output: { max_lines: 10, max_bytes: 1024 * 1024 } })
lineIt.live("output() truncates to configured max_lines", () =>
Effect.gen(function* () {
const content = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
const result = yield* (yield* Truncate.Service).output(content)
expect(result.truncated).toBe(true)
expect(result.content).toContain("...90 lines truncated...")
}),
)
// Huge line budget isolates byte truncation.
const byteIt = configuredIt({ tool_output: { max_lines: 1_000_000, max_bytes: 100 } })
byteIt.live("output() truncates to configured max_bytes", () =>
Effect.gen(function* () {
const content = "a".repeat(1000)
const result = yield* (yield* Truncate.Service).output(content)
expect(result.truncated).toBe(true)
expect(result.content).toContain("bytes truncated...")
}),
)
const overrideIt = configuredIt({ tool_output: { max_lines: 10, max_bytes: 100 } })
overrideIt.live("per-call options still override config", () =>
Effect.gen(function* () {
const content = Array.from({ length: 50 }, (_, i) => `line${i}`).join("\n")
const result = yield* (yield* Truncate.Service).output(content, {
maxLines: 1000,
maxBytes: 1024 * 1024,
})
expect(result.truncated).toBe(false)
}),
)
})
it.live("large single-line file truncates with byte message", () => it.live("large single-line file truncates with byte message", () =>
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Truncate.Service const svc = yield* Truncate.Service