refactor(opencode): model truncation limits as Option

Replace the `{ enabled: boolean; maxLines: number; maxBytes: number }`
shape with `Option<{ maxLines: number; maxBytes: number }>` so the absence
of limits is represented by the type system instead of a boolean flag with
dead numeric fields.

- Truncate.limits() now returns Effect<Option<Limits>>.
- Truncate.output() short-circuits on None instead of checking .enabled.
- shell tool gates rolling buffer, disk spill, and tail truncation on
  Option.isSome(limits); behavior unchanged when truncation is enabled.
- ShellPrompt.render and helpers accept Option<Limits>; the truncation
  guidance line is omitted when None.
- Tests updated to assert Option.isSome / Option.isNone.
This commit is contained in:
Aiden Cline 2026-05-24 23:06:06 -05:00
commit 93cc5e8dff
4 changed files with 43 additions and 33 deletions

View file

@ -1,4 +1,4 @@
import { Effect, Stream } from "effect" import { Effect, Option, Stream } from "effect"
import os from "os" import os from "os"
import { createWriteStream } from "node:fs" import { createWriteStream } from "node:fs"
import * as Tool from "./tool" import * as Tool from "./tool"
@ -433,7 +433,10 @@ export const ShellTool = Tool.define(
ctx: Tool.Context, ctx: Tool.Context,
) { ) {
const limits = yield* trunc.limits() const limits = yield* trunc.limits()
const keep = limits.enabled ? limits.maxBytes * 2 : Number.POSITIVE_INFINITY const keep = Option.match(limits, {
onNone: () => Number.POSITIVE_INFINITY,
onSome: (l) => l.maxBytes * 2,
})
let full = "" let full = ""
let last = "" let last = ""
const list: Chunk[] = [] const list: Chunk[] = []
@ -499,7 +502,7 @@ export const ShellTool = Tool.define(
sink?.write(chunk) sink?.write(chunk)
} else { } else {
full += chunk full += chunk
if (limits.enabled && Buffer.byteLength(full, "utf-8") > limits.maxBytes) { if (Option.isSome(limits) && Buffer.byteLength(full, "utf-8") > limits.value.maxBytes) {
return trunc.write(full).pipe( return trunc.write(full).pipe(
Effect.andThen((next) => Effect.andThen((next) =>
Effect.sync(() => { Effect.sync(() => {
@ -566,7 +569,10 @@ export const ShellTool = 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 = limits.enabled ? tail(raw, limits.maxLines, limits.maxBytes) : { text: raw, cut: false } const end = Option.match(limits, {
onNone: () => ({ text: raw, cut: false }),
onSome: (l) => tail(raw, l.maxLines, l.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)

View file

@ -1,4 +1,4 @@
import { Schema } from "effect" import { Option, Schema } from "effect"
import DESCRIPTION from "./shell.txt" import DESCRIPTION from "./shell.txt"
import { PositiveInt } from "@opencode-ai/core/schema" import { PositiveInt } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/core/global" import { Global } from "@opencode-ai/core/global"
@ -15,7 +15,6 @@ const descriptions = {
} }
export type Limits = { export type Limits = {
enabled: boolean
maxLines: number maxLines: number
maxBytes: number maxBytes: number
} }
@ -84,12 +83,12 @@ function chainGuidance(name: string) {
return "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." return "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."
} }
function truncationGuidance(limits: Limits, commands: string) { function truncationGuidance(limits: Option.Option<Limits>, commands: string) {
if (!limits.enabled) return "" if (Option.isNone(limits)) return ""
return `\n - If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use ${commands} to limit output; the full output will already be captured to a file for more precise searching.` return `\n - If the output exceeds ${limits.value.maxLines} lines or ${limits.value.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use ${commands} to limit output; the full output will already be captured to a file for more precise searching.`
} }
function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number) { function bashCommandSection(chain: string, limits: Option.Option<Limits>, defaultTimeoutMs: number) {
return `Before executing the command, please follow these steps: return `Before executing the command, please follow these steps:
1. Directory Verification: 1. Directory Verification:
@ -136,7 +135,7 @@ function powershellCommandSection(
name: string, name: string,
chain: string, chain: string,
pathSep: string, pathSep: string,
limits: Limits, limits: Option.Option<Limits>,
defaultTimeoutMs: number, defaultTimeoutMs: number,
) { ) {
return `${powershellNotes(name)} return `${powershellNotes(name)}
@ -183,7 +182,7 @@ Usage notes:
</bad-example>` </bad-example>`
} }
function cmdCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number) { function cmdCommandSection(chain: string, limits: Option.Option<Limits>, defaultTimeoutMs: number) {
return `# cmd.exe shell notes return `# cmd.exe shell notes
- Use double quotes for paths with spaces. - Use double quotes for paths with spaces.
- Use %VAR% for environment variables. - Use %VAR% for environment variables.
@ -232,7 +231,7 @@ Usage notes:
</bad-example>` </bad-example>`
} }
function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number) { function profile(name: string, platform: NodeJS.Platform, limits: Option.Option<Limits>, defaultTimeoutMs: number) {
const isPowerShell = PS.has(name) const isPowerShell = PS.has(name)
const chain = chainGuidance(name) const chain = chainGuidance(name)
if (CMD.has(name)) { if (CMD.has(name)) {
@ -287,7 +286,7 @@ function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaul
} }
} }
export function render(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number) { export function render(name: string, platform: NodeJS.Platform, limits: Option.Option<Limits>, defaultTimeoutMs: number) {
const selected = profile(name, platform, limits, defaultTimeoutMs) const selected = profile(name, platform, limits, defaultTimeoutMs)
return { return {
description: renderPrompt(DESCRIPTION, { description: renderPrompt(DESCRIPTION, {

View file

@ -19,7 +19,7 @@ export const DIR = TRUNCATION_DIR
export const GLOB = path.join(TRUNCATION_DIR, "*") export const GLOB = path.join(TRUNCATION_DIR, "*")
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
export type Limits = { enabled: boolean; maxLines: number; maxBytes: number } export type Limits = { maxLines: number; maxBytes: number }
export interface Options { export interface Options {
maxLines?: number maxLines?: number
@ -41,9 +41,11 @@ export interface Interface {
*/ */
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 state and limits from `tool_output` in opencode config. * Resolved truncation limits from `tool_output` in opencode config.
* Returns `None` when the user has disabled truncation (`tool_output.truncate: false`),
* in which case callers should pass output through without enforcing thresholds.
*/ */
readonly limits: () => Effect.Effect<Limits> readonly limits: () => Effect.Effect<Option.Option<Limits>>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Truncate") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Truncate") {}
@ -76,22 +78,21 @@ export const layer = Layer.effect(
const limits = Effect.fn("Truncate.limits")(function* () { const limits = Effect.fn("Truncate.limits")(function* () {
const configSvc = yield* Effect.serviceOption(Config.Service) const configSvc = yield* Effect.serviceOption(Config.Service)
if (Option.isNone(configSvc)) return { enabled: true, maxLines: MAX_LINES, maxBytes: MAX_BYTES } if (Option.isNone(configSvc)) return Option.some({ maxLines: MAX_LINES, maxBytes: MAX_BYTES })
const cfg = yield* configSvc.value.get().pipe(Effect.catch(() => Effect.succeed(undefined))) const cfg = yield* configSvc.value.get().pipe(Effect.catch(() => Effect.succeed(undefined)))
const tool_output = cfg?.tool_output const tool_output = cfg?.tool_output
if (tool_output?.truncate === false) return { enabled: false, maxLines: MAX_LINES, maxBytes: MAX_BYTES } if (tool_output?.truncate === false) return Option.none<Limits>()
return { return Option.some({
enabled: true,
maxLines: tool_output?.max_lines ?? MAX_LINES, maxLines: tool_output?.max_lines ?? MAX_LINES,
maxBytes: tool_output?.max_bytes ?? MAX_BYTES, maxBytes: 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 resolved = yield* limits() const resolved = yield* limits()
if (!resolved.enabled) return { content: text, truncated: false } as const if (Option.isNone(resolved)) return { content: text, truncated: false } as const
const maxLines = options.maxLines ?? resolved.maxLines const maxLines = options.maxLines ?? resolved.value.maxLines
const maxBytes = options.maxBytes ?? resolved.maxBytes const maxBytes = options.maxBytes ?? resolved.value.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")

View file

@ -1,7 +1,7 @@
import { describe, test, expect } from "bun:test" import { describe, test, expect } from "bun:test"
import { NodeFileSystem } from "@effect/platform-node" import { NodeFileSystem } from "@effect/platform-node"
import { AppFileSystem } from "@opencode-ai/core/filesystem" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect, FileSystem, Layer } from "effect" import { Effect, FileSystem, Layer, Option } from "effect"
import { Truncate } from "@/tool/truncate" import { Truncate } from "@/tool/truncate"
import { Config } from "@/config/config" import { Config } from "@/config/config"
import { Identifier } from "../../src/id/id" import { Identifier } from "../../src/id/id"
@ -110,9 +110,11 @@ describe("Truncate", () => {
Effect.gen(function* () { Effect.gen(function* () {
const svc = yield* Truncate.Service const svc = yield* Truncate.Service
const resolved = yield* svc.limits() const resolved = yield* svc.limits()
expect(resolved.enabled).toBe(true) expect(Option.isSome(resolved)).toBe(true)
expect(resolved.maxLines).toBe(Truncate.MAX_LINES) if (Option.isSome(resolved)) {
expect(resolved.maxBytes).toBe(Truncate.MAX_BYTES) expect(resolved.value.maxLines).toBe(Truncate.MAX_LINES)
expect(resolved.value.maxBytes).toBe(Truncate.MAX_BYTES)
}
}), }),
) )
@ -121,9 +123,11 @@ describe("Truncate", () => {
limitsIt.live("limits() reflects config overrides", () => limitsIt.live("limits() reflects config overrides", () =>
Effect.gen(function* () { Effect.gen(function* () {
const resolved = yield* (yield* Truncate.Service).limits() const resolved = yield* (yield* Truncate.Service).limits()
expect(resolved.enabled).toBe(true) expect(Option.isSome(resolved)).toBe(true)
expect(resolved.maxLines).toBe(123) if (Option.isSome(resolved)) {
expect(resolved.maxBytes).toBe(456) expect(resolved.value.maxLines).toBe(123)
expect(resolved.value.maxBytes).toBe(456)
}
}), }),
) )
@ -169,7 +173,7 @@ describe("Truncate", () => {
const svc = yield* Truncate.Service const svc = yield* Truncate.Service
const resolved = yield* svc.limits() const resolved = yield* svc.limits()
const result = yield* svc.output(content) const result = yield* svc.output(content)
expect(resolved.enabled).toBe(false) expect(Option.isNone(resolved)).toBe(true)
expect(result).toEqual({ content, truncated: false }) expect(result).toEqual({ content, truncated: false })
}), }),
) )