feat(core): allow disabling tool output truncation

This commit is contained in:
Aiden Cline 2026-05-22 17:20:58 -05:00
commit 1edba25dfb
9 changed files with 87 additions and 25 deletions

View file

@ -253,17 +253,20 @@ export const Info = Schema.Struct({
}), }),
), ),
tool_output: Schema.optional( tool_output: Schema.optional(
Schema.Struct({ Schema.Union([
max_lines: Schema.optional(PositiveInt).annotate({ Schema.Literal(false),
description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)", 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)",
}),
}), }),
max_bytes: Schema.optional(PositiveInt).annotate({ ]),
description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",
}),
}),
).annotate({ ).annotate({
description: 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, or set to false to disable it. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",
}), }),
compaction: Schema.optional( compaction: Schema.optional(
Schema.Struct({ Schema.Struct({

View file

@ -126,7 +126,7 @@ Every field is optional.
"mcp_timeout": 30000 "mcp_timeout": 30000
}, },
"tool_output": { "max_lines": 200, "max_bytes": 8192 }, "tool_output": false | { "max_lines": 200, "max_bytes": 8192 },
"compaction": { "auto": true, "tail_turns": 15 } "compaction": { "auto": true, "tail_turns": 15 }
} }
@ -140,6 +140,7 @@ Shape notes worth being explicit about:
- `plugin` is an array of strings or `[name, options]` tuples, not an object. - `plugin` is an array of strings or `[name, options]` tuples, not an object.
- `mcp[name].command` is an array of strings, never a single string. `type` is required. - `mcp[name].command` is an array of strings, never a single string. `type` is required.
- `permission` is either a string action or an object keyed by tool name. - `permission` is either a string action or an object keyed by tool name.
- `tool_output: false` disables shared tool output truncation; use an object to override its line and byte thresholds.
## Skills ## Skills

View file

@ -433,7 +433,7 @@ export const ShellTool = Tool.define(
ctx: Tool.Context, ctx: Tool.Context,
) { ) {
const limits = yield* trunc.limits() const limits = yield* trunc.limits()
const keep = limits.maxBytes * 2 const keep = limits.enabled ? limits.maxBytes * 2 : Number.POSITIVE_INFINITY
let full = "" let full = ""
let last = "" let last = ""
const list: Chunk[] = [] const list: Chunk[] = []
@ -499,7 +499,7 @@ export const ShellTool = Tool.define(
sink?.write(chunk) sink?.write(chunk)
} else { } else {
full += chunk full += chunk
if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) { if (limits.enabled && Buffer.byteLength(full, "utf-8") > limits.maxBytes) {
return trunc.write(full).pipe( return trunc.write(full).pipe(
Effect.andThen((next) => Effect.andThen((next) =>
Effect.sync(() => { Effect.sync(() => {
@ -566,7 +566,7 @@ 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 = tail(raw, limits.maxLines, limits.maxBytes) const end = limits.enabled ? tail(raw, limits.maxLines, limits.maxBytes) : { text: raw, cut: false }
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

@ -15,6 +15,7 @@ const descriptions = {
} }
export type Limits = { export type Limits = {
enabled: boolean
maxLines: number maxLines: number
maxBytes: number maxBytes: number
} }
@ -83,6 +84,11 @@ 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) {
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 bashCommandSection(chain: string, limits: Limits) { function bashCommandSection(chain: string, limits: Limits) {
return `Before executing the command, please follow these steps: return `Before executing the command, please follow these steps:
@ -103,8 +109,7 @@ function bashCommandSection(chain: string, limits: Limits) {
Usage notes: Usage notes:
- The command argument is required. - The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - 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")}
- 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.
- 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: - 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) - File search: Use Glob (NOT find or ls)
@ -149,8 +154,7 @@ Before executing the command, please follow these steps:
Usage notes: Usage notes:
- The command argument is required. - The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - 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")}
- 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.
- 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: - 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) - File search: Use Glob (NOT Get-ChildItem)
@ -199,8 +203,7 @@ Before executing the command, please follow these steps:
Usage notes: Usage notes:
- The command argument is required. - The command argument is required.
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes). - You can specify an optional timeout in milliseconds. If not specified, commands will time out after 120000ms (2 minutes).
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words. - 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")}
- 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.
- 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: - 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) - File search: Use Glob (NOT dir /s)

View file

@ -19,6 +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 interface Options { export interface Options {
maxLines?: number maxLines?: number
@ -40,9 +41,9 @@ 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 limits: values from `tool_output` in opencode config, or MAX_LINES / MAX_BYTES if unset. * Resolved truncation state and limits from `tool_output` in opencode config.
*/ */
readonly limits: () => Effect.Effect<{ maxLines: number; maxBytes: number }> readonly limits: () => Effect.Effect<Limits>
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/Truncate") {} export class Service extends Context.Service<Service, Interface>()("@opencode/Truncate") {}
@ -75,9 +76,11 @@ 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 { maxLines: MAX_LINES, maxBytes: MAX_BYTES } if (Option.isNone(configSvc)) return { enabled: true, 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)))
if (cfg?.tool_output === false) return { enabled: false, maxLines: MAX_LINES, maxBytes: MAX_BYTES }
return { return {
enabled: true,
maxLines: cfg?.tool_output?.max_lines ?? MAX_LINES, maxLines: cfg?.tool_output?.max_lines ?? MAX_LINES,
maxBytes: cfg?.tool_output?.max_bytes ?? MAX_BYTES, maxBytes: cfg?.tool_output?.max_bytes ?? MAX_BYTES,
} }
@ -85,6 +88,7 @@ export const layer = Layer.effect(
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
const maxLines = options.maxLines ?? resolved.maxLines const maxLines = options.maxLines ?? resolved.maxLines
const maxBytes = options.maxBytes ?? resolved.maxBytes const maxBytes = options.maxBytes ?? resolved.maxBytes
const direction = options.direction ?? "head" const direction = options.direction ?? "head"

View file

@ -1200,6 +1200,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: 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", () => it.live("full output is saved to file when truncated", () =>
runIn( runIn(
projectRoot, projectRoot,

View file

@ -110,6 +110,7 @@ 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(resolved.maxLines).toBe(Truncate.MAX_LINES) expect(resolved.maxLines).toBe(Truncate.MAX_LINES)
expect(resolved.maxBytes).toBe(Truncate.MAX_BYTES) expect(resolved.maxBytes).toBe(Truncate.MAX_BYTES)
}), }),
@ -120,6 +121,7 @@ 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(resolved.maxLines).toBe(123) expect(resolved.maxLines).toBe(123)
expect(resolved.maxBytes).toBe(456) expect(resolved.maxBytes).toBe(456)
}), }),
@ -159,6 +161,18 @@ describe("Truncate", () => {
expect(result.truncated).toBe(false) expect(result.truncated).toBe(false)
}), }),
) )
const disabledIt = configuredIt({ tool_output: 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(resolved.enabled).toBe(false)
expect(result).toEqual({ content, truncated: false })
}),
)
}) })
it.live("large single-line file truncates with byte message", () => it.live("large single-line file truncates with byte message", () =>

View file

@ -1294,10 +1294,15 @@ export type Config = {
enterprise?: { enterprise?: {
url?: string url?: string
} }
tool_output?: { /**
max_lines?: number * Configure tool output truncation, or set to false to disable it. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.
max_bytes?: number */
} tool_output?:
| false
| {
max_lines?: number
max_bytes?: number
}
compaction?: { compaction?: {
auto?: boolean auto?: boolean
prune?: boolean prune?: boolean

View file

@ -372,6 +372,15 @@ You can control when tool output is truncated using the `tool_output` option. Wh
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. 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 `tool_output` to `false`:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"tool_output": false
}
```
--- ---
### Models ### Models