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:
@@ -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)
|
||||
|
||||
@@ -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<Limits>, 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<Limits>, 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<Limits>,
|
||||
defaultTimeoutMs: number,
|
||||
) {
|
||||
return `${powershellNotes(name)}
|
||||
@@ -183,7 +182,7 @@ Usage notes:
|
||||
</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
|
||||
- Use double quotes for paths with spaces.
|
||||
- Use %VAR% for environment variables.
|
||||
@@ -232,7 +231,7 @@ Usage notes:
|
||||
</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 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<Limits>, defaultTimeoutMs: number) {
|
||||
const selected = profile(name, platform, limits, defaultTimeoutMs)
|
||||
return {
|
||||
description: renderPrompt(DESCRIPTION, {
|
||||
|
||||
@@ -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<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") {}
|
||||
@@ -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<Limits>()
|
||||
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")
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user