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 }) }), )