Skip to content

Commit 72ccf29

Browse files
1rgsrgs_ramprekram1-node
authored andcommitted
feat(truncate): allow configuring tool output truncation limits (anomalyco#23770)
Co-authored-by: rgs_ramp <[email protected]> Co-authored-by: Aiden Cline <[email protected]> (cherry picked from commit f8c6ddd)
1 parent 8a1f79d commit 72ccf29

4 files changed

Lines changed: 106 additions & 12 deletions

File tree

packages/opencode/src/config/config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,19 @@ export const Info = Schema.Struct({
208208
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
209209
}),
210210
),
211+
tool_output: Schema.optional(
212+
Schema.Struct({
213+
max_lines: Schema.optional(PositiveInt).annotate({
214+
description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)",
215+
}),
216+
max_bytes: Schema.optional(PositiveInt).annotate({
217+
description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",
218+
}),
219+
}),
220+
).annotate({
221+
description:
222+
"Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",
223+
}),
211224
compaction: Schema.optional(
212225
Schema.Struct({
213226
auto: Schema.optional(Schema.Boolean).annotate({

packages/opencode/src/tool/bash.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -420,9 +420,8 @@ export const BashTool = Tool.define(
420420
},
421421
ctx: Tool.Context,
422422
) {
423-
const bytes = Truncate.MAX_BYTES
424-
const lines = Truncate.MAX_LINES
425-
const keep = bytes * 2
423+
const limits = yield* trunc.limits()
424+
const keep = limits.maxBytes * 2
426425
let full = ""
427426
let last = ""
428427
const list: Chunk[] = []
@@ -462,7 +461,7 @@ export const BashTool = Tool.define(
462461
sink?.write(chunk)
463462
} else {
464463
full += chunk
465-
if (Buffer.byteLength(full, "utf-8") > bytes) {
464+
if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) {
466465
return trunc.write(full).pipe(
467466
Effect.andThen((next) =>
468467
Effect.sync(() => {
@@ -529,7 +528,7 @@ export const BashTool = Tool.define(
529528
}
530529
if (aborted) meta.push("User aborted the command")
531530
const raw = list.map((item) => item.text).join("")
532-
const end = tail(raw, lines, bytes)
531+
const end = tail(raw, limits.maxLines, limits.maxBytes)
533532
if (end.cut) cut = true
534533
if (!file && end.cut) {
535534
file = yield* trunc.write(raw)
@@ -570,7 +569,7 @@ export const BashTool = Tool.define(
570569
})
571570

572571
return () =>
573-
Effect.sync(() => {
572+
Effect.gen(function* () {
574573
const shell = Shell.acceptable()
575574
const name = Shell.name(shell)
576575
const chain =
@@ -579,13 +578,15 @@ export const BashTool = Tool.define(
579578
: "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."
580579
log.info("bash tool using shell", { shell })
581580

581+
const limits = yield* trunc.limits()
582+
582583
return {
583584
description: DESCRIPTION.replaceAll("${directory}", Instance.directory)
584585
.replaceAll("${os}", process.platform)
585586
.replaceAll("${shell}", name)
586587
.replaceAll("${chaining}", chain)
587-
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
588-
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
588+
.replaceAll("${maxLines}", String(limits.maxLines))
589+
.replaceAll("${maxBytes}", String(limits.maxBytes)),
589590
parameters: Parameters,
590591
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
591592
Effect.gen(function* () {

packages/opencode/src/tool/truncate.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { NodePath } from "@effect/platform-node"
2-
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
2+
import { Cause, Duration, Effect, Layer, Option, Schedule, Context } from "effect"
33
import path from "path"
44
import type { Agent } from "../agent/agent"
55
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
66
import { evaluate } from "@/permission/evaluate"
7+
import { Config } from "../config"
78
import { Identifier } from "../id/id"
89
import { Log } from "../util"
910
import { ToolID } from "./schema"
@@ -38,6 +39,10 @@ export interface Interface {
3839
* to the truncation directory and returns a preview plus a hint to inspect the saved file.
3940
*/
4041
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
42+
/**
43+
* Resolved truncation limits: values from `tool_output` in opencode config, or MAX_LINES / MAX_BYTES if unset.
44+
*/
45+
readonly limits: () => Effect.Effect<{ maxLines: number; maxBytes: number }>
4146
}
4247

4348
export class Service extends Context.Service<Service, Interface>()("@opencode/Truncate") {}
@@ -68,9 +73,20 @@ export const layer = Layer.effect(
6873
return file
6974
})
7075

76+
const limits = Effect.fn("Truncate.limits")(function* () {
77+
const configSvc = yield* Effect.serviceOption(Config.Service)
78+
if (Option.isNone(configSvc)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
79+
const cfg = yield* configSvc.value.get().pipe(Effect.catch(() => Effect.succeed(undefined)))
80+
return {
81+
maxLines: cfg?.tool_output?.max_lines ?? MAX_LINES,
82+
maxBytes: cfg?.tool_output?.max_bytes ?? MAX_BYTES,
83+
}
84+
})
85+
7186
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
72-
const maxLines = options.maxLines ?? MAX_LINES
73-
const maxBytes = options.maxBytes ?? MAX_BYTES
87+
const resolved = yield* limits()
88+
const maxLines = options.maxLines ?? resolved.maxLines
89+
const maxBytes = options.maxBytes ?? resolved.maxBytes
7490
const direction = options.direction ?? "head"
7591
const lines = text.split("\n")
7692
const totalBytes = Buffer.byteLength(text, "utf-8")
@@ -135,7 +151,7 @@ export const layer = Layer.effect(
135151
Effect.forkScoped,
136152
)
137153

138-
return Service.of({ cleanup, write, output })
154+
return Service.of({ cleanup, write, output, limits })
139155
}),
140156
)
141157

packages/opencode/test/tool/truncation.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test"
22
import { NodeFileSystem } from "@effect/platform-node"
33
import { Effect, FileSystem, Layer } from "effect"
44
import { Truncate } from "../../src/tool"
5+
import { Config } from "../../src/config"
56
import { Identifier } from "../../src/id/id"
67
import { Process } from "../../src/util"
78
import { Filesystem } from "../../src/util"
@@ -15,6 +16,14 @@ const ROOT = path.resolve(import.meta.dir, "..", "..")
1516

1617
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer))
1718

19+
const configuredLayer = (cfg: Config.Info) =>
20+
Layer.mergeAll(
21+
Truncate.defaultLayer,
22+
NodeFileSystem.layer,
23+
Layer.mock(Config.Service)({ get: () => Effect.succeed(cfg) }),
24+
)
25+
const configuredIt = (cfg: Config.Info) => testEffect(configuredLayer(cfg))
26+
1827
describe("Truncate", () => {
1928
describe("output", () => {
2029
it.live("truncates large json file by bytes", () =>
@@ -95,6 +104,61 @@ describe("Truncate", () => {
95104
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
96105
})
97106

107+
it.live("limits() falls back to MAX_LINES/MAX_BYTES when Config is not provided", () =>
108+
Effect.gen(function* () {
109+
const svc = yield* Truncate.Service
110+
const resolved = yield* svc.limits()
111+
expect(resolved.maxLines).toBe(Truncate.MAX_LINES)
112+
expect(resolved.maxBytes).toBe(Truncate.MAX_BYTES)
113+
}),
114+
)
115+
116+
describe("with tool_output config", () => {
117+
const limitsIt = configuredIt({ tool_output: { max_lines: 123, max_bytes: 456 } })
118+
limitsIt.live("limits() reflects config overrides", () =>
119+
Effect.gen(function* () {
120+
const resolved = yield* (yield* Truncate.Service).limits()
121+
expect(resolved.maxLines).toBe(123)
122+
expect(resolved.maxBytes).toBe(456)
123+
}),
124+
)
125+
126+
// Huge byte budget isolates line truncation. 100 lines against max_lines: 10
127+
// proves the configured line limit is what `output()` enforces.
128+
const lineIt = configuredIt({ tool_output: { max_lines: 10, max_bytes: 1024 * 1024 } })
129+
lineIt.live("output() truncates to configured max_lines", () =>
130+
Effect.gen(function* () {
131+
const content = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
132+
const result = yield* (yield* Truncate.Service).output(content)
133+
expect(result.truncated).toBe(true)
134+
expect(result.content).toContain("...90 lines truncated...")
135+
}),
136+
)
137+
138+
// Huge line budget isolates byte truncation.
139+
const byteIt = configuredIt({ tool_output: { max_lines: 1_000_000, max_bytes: 100 } })
140+
byteIt.live("output() truncates to configured max_bytes", () =>
141+
Effect.gen(function* () {
142+
const content = "a".repeat(1000)
143+
const result = yield* (yield* Truncate.Service).output(content)
144+
expect(result.truncated).toBe(true)
145+
expect(result.content).toContain("bytes truncated...")
146+
}),
147+
)
148+
149+
const overrideIt = configuredIt({ tool_output: { max_lines: 10, max_bytes: 100 } })
150+
overrideIt.live("per-call options still override config", () =>
151+
Effect.gen(function* () {
152+
const content = Array.from({ length: 50 }, (_, i) => `line${i}`).join("\n")
153+
const result = yield* (yield* Truncate.Service).output(content, {
154+
maxLines: 1000,
155+
maxBytes: 1024 * 1024,
156+
})
157+
expect(result.truncated).toBe(false)
158+
}),
159+
)
160+
})
161+
98162
it.live("large single-line file truncates with byte message", () =>
99163
Effect.gen(function* () {
100164
const svc = yield* Truncate.Service

0 commit comments

Comments
 (0)