Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,17 +253,25 @@ export const Info = Schema.Struct({
}),
),
Comment thread
rekram1-node marked this conversation as resolved.
tool_output: Schema.optional(
Schema.Struct({
max_lines: Schema.optional(PositiveInt).annotate({
description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)",
}),
max_bytes: Schema.optional(PositiveInt).annotate({
description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",
Schema.Union([
Schema.Struct({
truncate: Schema.Literal(false).annotate({ description: "Disable tool output truncation" }),
}).annotate({ parseOptions: { onExcessProperty: "error" } }),
Schema.Struct({
truncate: Schema.optional(Schema.Literal(true)).annotate({
description: "Enable truncating tool output that exceeds the configured limits (default: true)",
}),
max_lines: Schema.optional(PositiveInt).annotate({
description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)",
}),
max_bytes: Schema.optional(PositiveInt).annotate({
description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",
}),
}),
}),
]),
).annotate({
description:
"Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",
"Configure tool output truncation. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",
}),
compaction: Schema.optional(
Schema.Struct({
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/skill/prompt/customize-opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ shape before writing config, **fetch that URL and read the schema directly**
rather than guessing. opencode hard-fails on invalid config, so the cost of a
wrong shape is a broken startup.

The full schema is large. Prefer using JavaScript or Bash to fetch and extract
the relevant property definition instead of reading the entire schema into
context when you only need to check one setting.

Independently, every `opencode.json` should declare
`"$schema": "https://opencode.ai/config.json"` so the user's editor catches
mistakes as they type.
Expand Down
14 changes: 10 additions & 4 deletions packages/opencode/src/tool/shell.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -433,7 +433,10 @@ export const ShellTool = Tool.define(
ctx: Tool.Context,
) {
const limits = yield* trunc.limits()
const keep = limits.maxBytes * 2
const keep = Option.match(limits, {
onNone: () => Number.POSITIVE_INFINITY,
onSome: (l) => l.maxBytes * 2,
})
let full = ""
let last = ""
const list: Chunk[] = []
Expand Down Expand Up @@ -499,7 +502,7 @@ export const ShellTool = Tool.define(
sink?.write(chunk)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: when tool_output is false, this condition prevents writing to disk, but full += chunk still runs on every chunk even though full is never read in the disabled path (the final output comes from list/raw). For large outputs this keeps a second copy of the whole stream in memory. Consider skipping the full buffer entirely when limits is None so disabled truncation only stores the one buffer needed to return the untruncated output.

} else {
full += chunk
if (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(() => {
Expand Down Expand Up @@ -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 = tail(raw, limits.maxLines, limits.maxBytes)
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)
Expand Down
26 changes: 14 additions & 12 deletions packages/opencode/src/tool/shell/prompt.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -83,7 +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 bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number) {
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: Option.Option<Limits>, defaultTimeoutMs: number) {
return `Before executing the command, please follow these steps:

1. Directory Verification:
Expand All @@ -103,8 +108,7 @@ function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: num
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- 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 \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.${truncationGuidance(limits, "`head`, `tail`, or other truncation commands")}

- Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use Glob (NOT find or ls)
Expand All @@ -131,7 +135,7 @@ function powershellCommandSection(
name: string,
chain: string,
pathSep: string,
limits: Limits,
limits: Option.Option<Limits>,
defaultTimeoutMs: number,
) {
return `${powershellNotes(name)}
Expand All @@ -155,8 +159,7 @@ Before executing the command, please follow these steps:
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- 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 \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.${truncationGuidance(limits, "`Select-Object -First`, `Select-Object -Last`, or other truncation commands")}

- Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use Glob (NOT Get-ChildItem)
Expand All @@ -179,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.
Expand All @@ -205,8 +208,7 @@ Before executing the command, please follow these steps:
Usage notes:
- The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
- 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 \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching.
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.${truncationGuidance(limits, "`more` or other pagination commands")}

- Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
- File search: Use Glob (NOT dir /s)
Expand All @@ -229,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)) {
Expand Down Expand Up @@ -284,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, {
Expand Down
24 changes: 15 additions & 9 deletions packages/opencode/src/tool/truncate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +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 = { maxLines: number; maxBytes: number }

export interface Options {
maxLines?: number
Expand All @@ -40,9 +41,11 @@ export interface Interface {
*/
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
/**
* Resolved truncation limits: values from `tool_output` in opencode config, or MAX_LINES / MAX_BYTES if unset.
* 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<{ maxLines: number; maxBytes: number }>
readonly limits: () => Effect.Effect<Option.Option<Limits>>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/Truncate") {}
Expand Down Expand Up @@ -75,18 +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 { 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)))
return {
maxLines: cfg?.tool_output?.max_lines ?? MAX_LINES,
maxBytes: cfg?.tool_output?.max_bytes ?? MAX_BYTES,
}
const tool_output = cfg?.tool_output
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()
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")
Expand Down
15 changes: 15 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1292,6 +1292,21 @@ test("config parser preserves permission order while rejecting unknown top-level
}
})

test("tool_output only accepts thresholds when truncation is enabled", () => {
expect(ConfigParse.schema(Config.Info, { tool_output: { truncate: false } }, "test").tool_output).toEqual({
truncate: false,
})
expect(
ConfigParse.schema(Config.Info, { tool_output: { max_lines: 200, max_bytes: 8192 } }, "test").tool_output,
).toEqual({
max_lines: 200,
max_bytes: 8192,
})
expect(() =>
ConfigParse.schema(Config.Info, { tool_output: { truncate: false, max_lines: 200, max_bytes: 8192 } }, "test"),
).toThrow()
})

// MCP config merging tests

it.instance("project config can override MCP server enabled status", () =>
Expand Down
23 changes: 23 additions & 0 deletions packages/opencode/test/tool/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,29 @@ describe("tool.shell truncation", () => {
),
)

it.live("does not truncate output when tool_output is disabled", () =>
Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ config: { tool_output: { truncate: false } } })
yield* runIn(
tmp,
Effect.gen(function* () {
const bash = yield* initShell()
expect(bash.description).not.toContain("If the output exceeds")
const result = yield* bash.execute(
{
command: fill("bytes", Truncate.MAX_BYTES + 10000),
description: "Generate bytes with truncation disabled",
},
ctx,
)
expect(result.metadata.truncated).toBe(false)
expect(result.output).not.toContain("...output truncated...")
expect(Buffer.byteLength(result.output, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
}),
)
}),
)

it.live("full output is saved to file when truncated", () =>
runIn(
projectRoot,
Expand Down
28 changes: 23 additions & 5 deletions packages/opencode/test/tool/truncation.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -110,8 +110,11 @@ describe("Truncate", () => {
Effect.gen(function* () {
const svc = yield* Truncate.Service
const resolved = yield* svc.limits()
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)
}
}),
)

Expand All @@ -120,8 +123,11 @@ describe("Truncate", () => {
limitsIt.live("limits() reflects config overrides", () =>
Effect.gen(function* () {
const resolved = yield* (yield* Truncate.Service).limits()
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)
}
}),
)

Expand Down Expand Up @@ -159,6 +165,18 @@ describe("Truncate", () => {
expect(result.truncated).toBe(false)
}),
)

const disabledIt = configuredIt({ tool_output: { truncate: false } })
disabledIt.live("does not truncate output when disabled", () =>
Effect.gen(function* () {
const content = "a".repeat(Truncate.MAX_BYTES + 1)
const svc = yield* Truncate.Service
const resolved = yield* svc.limits()
const result = yield* svc.output(content)
expect(Option.isNone(resolved)).toBe(true)
expect(result).toEqual({ content, truncated: false })
}),
)
})

it.live("large single-line file truncates with byte message", () =>
Expand Down
19 changes: 15 additions & 4 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1294,10 +1294,21 @@ export type Config = {
enterprise?: {
url?: string
}
tool_output?: {
max_lines?: number
max_bytes?: number
}
/**
* Configure tool output truncation. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.
*/
tool_output?:
| {
/**
* Disable tool output truncation
*/
truncate: false
}
| {
truncate?: true
max_lines?: number
max_bytes?: number
}
compaction?: {
auto?: boolean
prune?: boolean
Expand Down
36 changes: 36 additions & 0 deletions packages/web/src/content/docs/config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,42 @@ You can manage the tools an LLM can use through the `tools` option.

---

### Tool output

You can control when tool output is truncated using the `tool_output` option. When output exceeds either threshold, OpenCode saves the complete output to disk and returns a truncated preview with the saved file path.

```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"tool_output": {
"truncate": true,
"max_lines": 2000,
"max_bytes": 51200
}
}
```

- `truncate` - Truncate tool output when it exceeds a configured threshold (default: `true`).
- `max_lines` - Maximum number of lines before output is truncated (default: `2000`).
- `max_bytes` - Maximum size in bytes before output is truncated (default: `51200`).

These thresholds apply to output handled by OpenCode's shared truncation layer, including MCP and plugin tool output. Individual tools that page or cap their own results can have separate limits.

To disable shared tool output truncation, set `truncate` to `false`:

```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"tool_output": {
"truncate": false
}
}
```

`truncate: false` cannot be combined with `max_lines` or `max_bytes`; thresholds only apply when truncation is enabled.

---

### Models

You can configure the providers and models you want to use in your OpenCode config through the `provider`, `model` and `small_model` options.
Expand Down
Loading