From 93cc5e8dff7f06cdf27a04edc7f7c928fd4e7fdd Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 24 May 2026 23:06:06 -0500 Subject: [PATCH] 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>. - 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; the truncation guidance line is omitted when None. - Tests updated to assert Option.isSome / Option.isNone. --- packages/opencode/src/tool/shell.ts | 14 +++++++---- packages/opencode/src/tool/shell/prompt.ts | 19 ++++++++------- packages/opencode/src/tool/truncate.ts | 23 ++++++++++--------- .../opencode/test/tool/truncation.test.ts | 20 +++++++++------- 4 files changed, 43 insertions(+), 33 deletions(-) diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index 78e7e976d..722ee6d2b 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -1,4 +1,4 @@ -import { Effect, Stream } from "effect" +import { Effect, Option, Stream } from "effect" import os from "os" import { createWriteStream } from "node:fs" import * as Tool from "./tool" @@ -433,7 +433,10 @@ export const ShellTool = Tool.define( ctx: Tool.Context, ) { 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 last = "" const list: Chunk[] = [] @@ -499,7 +502,7 @@ export const ShellTool = Tool.define( sink?.write(chunk) } else { 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( Effect.andThen((next) => Effect.sync(() => { @@ -566,7 +569,10 @@ export const ShellTool = Tool.define( } if (aborted) meta.push("User aborted the command") 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 (!file && end.cut) { file = yield* trunc.write(raw) diff --git a/packages/opencode/src/tool/shell/prompt.ts b/packages/opencode/src/tool/shell/prompt.ts index bd0fd274d..a5dcfb59a 100644 --- a/packages/opencode/src/tool/shell/prompt.ts +++ b/packages/opencode/src/tool/shell/prompt.ts @@ -1,4 +1,4 @@ -import { Schema } from "effect" +import { Option, Schema } from "effect" import DESCRIPTION from "./shell.txt" import { PositiveInt } from "@opencode-ai/core/schema" import { Global } from "@opencode-ai/core/global" @@ -15,7 +15,6 @@ const descriptions = { } export type Limits = { - enabled: boolean maxLines: 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." } -function truncationGuidance(limits: Limits, commands: string) { - if (!limits.enabled) 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.` +function truncationGuidance(limits: Option.Option, commands: string) { + if (Option.isNone(limits)) return "" + 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, defaultTimeoutMs: number) { return `Before executing the command, please follow these steps: 1. Directory Verification: @@ -136,7 +135,7 @@ function powershellCommandSection( name: string, chain: string, pathSep: string, - limits: Limits, + limits: Option.Option, defaultTimeoutMs: number, ) { return `${powershellNotes(name)} @@ -183,7 +182,7 @@ Usage notes: ` } -function cmdCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number) { +function cmdCommandSection(chain: string, limits: Option.Option, defaultTimeoutMs: number) { return `# cmd.exe shell notes - Use double quotes for paths with spaces. - Use %VAR% for environment variables. @@ -232,7 +231,7 @@ Usage notes: ` } -function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number) { +function profile(name: string, platform: NodeJS.Platform, limits: Option.Option, defaultTimeoutMs: number) { const isPowerShell = PS.has(name) const chain = chainGuidance(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, defaultTimeoutMs: number) { const selected = profile(name, platform, limits, defaultTimeoutMs) return { description: renderPrompt(DESCRIPTION, { diff --git a/packages/opencode/src/tool/truncate.ts b/packages/opencode/src/tool/truncate.ts index 455dc12bf..c92d8e31a 100644 --- a/packages/opencode/src/tool/truncate.ts +++ b/packages/opencode/src/tool/truncate.ts @@ -19,7 +19,7 @@ export const DIR = TRUNCATION_DIR export const GLOB = path.join(TRUNCATION_DIR, "*") 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 { maxLines?: number @@ -41,9 +41,11 @@ export interface Interface { */ readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect /** - * 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 + readonly limits: () => Effect.Effect> } export class Service extends Context.Service()("@opencode/Truncate") {} @@ -76,22 +78,21 @@ export const layer = Layer.effect( const limits = Effect.fn("Truncate.limits")(function* () { 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 tool_output = cfg?.tool_output - if (tool_output?.truncate === false) return { enabled: false, maxLines: MAX_LINES, maxBytes: MAX_BYTES } - return { - enabled: true, + if (tool_output?.truncate === false) return Option.none() + return Option.some({ maxLines: tool_output?.max_lines ?? MAX_LINES, maxBytes: tool_output?.max_bytes ?? MAX_BYTES, - } + }) }) const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) { const resolved = yield* limits() - if (!resolved.enabled) return { content: text, truncated: false } as const - const maxLines = options.maxLines ?? resolved.maxLines - const maxBytes = options.maxBytes ?? resolved.maxBytes + if (Option.isNone(resolved)) return { content: text, truncated: false } as const + const maxLines = options.maxLines ?? resolved.value.maxLines + const maxBytes = options.maxBytes ?? resolved.value.maxBytes const direction = options.direction ?? "head" const lines = text.split("\n") const totalBytes = Buffer.byteLength(text, "utf-8") diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index b0f946f58..dd0ca558b 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "bun:test" import { NodeFileSystem } from "@effect/platform-node" 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 { Config } from "@/config/config" import { Identifier } from "../../src/id/id" @@ -110,9 +110,11 @@ describe("Truncate", () => { Effect.gen(function* () { const svc = yield* Truncate.Service const resolved = yield* svc.limits() - expect(resolved.enabled).toBe(true) - expect(resolved.maxLines).toBe(Truncate.MAX_LINES) - expect(resolved.maxBytes).toBe(Truncate.MAX_BYTES) + expect(Option.isSome(resolved)).toBe(true) + if (Option.isSome(resolved)) { + 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", () => Effect.gen(function* () { const resolved = yield* (yield* Truncate.Service).limits() - expect(resolved.enabled).toBe(true) - expect(resolved.maxLines).toBe(123) - expect(resolved.maxBytes).toBe(456) + expect(Option.isSome(resolved)).toBe(true) + if (Option.isSome(resolved)) { + expect(resolved.value.maxLines).toBe(123) + expect(resolved.value.maxBytes).toBe(456) + } }), ) @@ -169,7 +173,7 @@ describe("Truncate", () => { const svc = yield* Truncate.Service const resolved = yield* svc.limits() const result = yield* svc.output(content) - expect(resolved.enabled).toBe(false) + expect(Option.isNone(resolved)).toBe(true) expect(result).toEqual({ content, truncated: false }) }), )