Compare commits
8 commits
dev
...
issue-1377
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d929f78c3 | ||
|
|
d4f83ba2b7 | ||
|
|
93cc5e8dff | ||
|
|
3fcdf94873 | ||
|
|
7cfcb644ab | ||
|
|
7a58560161 | ||
|
|
1edba25dfb | ||
|
|
6d3c4c916b |
10 changed files with 185 additions and 42 deletions
|
|
@ -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).annotate({ description: "Disable tool output truncation" }),
|
||||||
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. 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({
|
||||||
|
|
|
||||||
|
|
@ -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
|
rather than guessing. opencode hard-fails on invalid config, so the cost of a
|
||||||
wrong shape is a broken startup.
|
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
|
Independently, every `opencode.json` should declare
|
||||||
`"$schema": "https://opencode.ai/config.json"` so the user's editor catches
|
`"$schema": "https://opencode.ai/config.json"` so the user's editor catches
|
||||||
mistakes as they type.
|
mistakes as they type.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Effect, Stream } from "effect"
|
import { Effect, Option, Stream } from "effect"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import { createWriteStream } from "node:fs"
|
import { createWriteStream } from "node:fs"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
|
|
@ -433,7 +433,10 @@ 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 = Option.match(limits, {
|
||||||
|
onNone: () => Number.POSITIVE_INFINITY,
|
||||||
|
onSome: (l) => l.maxBytes * 2,
|
||||||
|
})
|
||||||
let full = ""
|
let full = ""
|
||||||
let last = ""
|
let last = ""
|
||||||
const list: Chunk[] = []
|
const list: Chunk[] = []
|
||||||
|
|
@ -499,7 +502,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 (Option.isSome(limits) && Buffer.byteLength(full, "utf-8") > limits.value.maxBytes) {
|
||||||
return trunc.write(full).pipe(
|
return trunc.write(full).pipe(
|
||||||
Effect.andThen((next) =>
|
Effect.andThen((next) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
|
|
@ -566,7 +569,10 @@ 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 = Option.match(limits, {
|
||||||
|
onNone: () => ({ text: raw, cut: false }),
|
||||||
|
onSome: (l) => tail(raw, l.maxLines, l.maxBytes),
|
||||||
|
})
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Schema } from "effect"
|
import { Option, Schema } from "effect"
|
||||||
import DESCRIPTION from "./shell.txt"
|
import DESCRIPTION from "./shell.txt"
|
||||||
import { PositiveInt } from "@opencode-ai/core/schema"
|
import { PositiveInt } from "@opencode-ai/core/schema"
|
||||||
import { Global } from "@opencode-ai/core/global"
|
import { Global } from "@opencode-ai/core/global"
|
||||||
|
|
@ -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."
|
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:
|
return `Before executing the command, please follow these steps:
|
||||||
|
|
||||||
1. Directory Verification:
|
1. Directory Verification:
|
||||||
|
|
@ -103,8 +108,7 @@ function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: num
|
||||||
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 ${defaultTimeoutMs}ms.
|
- 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.
|
- 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)
|
||||||
|
|
@ -131,7 +135,7 @@ function powershellCommandSection(
|
||||||
name: string,
|
name: string,
|
||||||
chain: string,
|
chain: string,
|
||||||
pathSep: string,
|
pathSep: string,
|
||||||
limits: Limits,
|
limits: Option.Option<Limits>,
|
||||||
defaultTimeoutMs: number,
|
defaultTimeoutMs: number,
|
||||||
) {
|
) {
|
||||||
return `${powershellNotes(name)}
|
return `${powershellNotes(name)}
|
||||||
|
|
@ -155,8 +159,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 ${defaultTimeoutMs}ms.
|
- 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.
|
- 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)
|
||||||
|
|
@ -179,7 +182,7 @@ Usage notes:
|
||||||
</bad-example>`
|
</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
|
return `# cmd.exe shell notes
|
||||||
- Use double quotes for paths with spaces.
|
- Use double quotes for paths with spaces.
|
||||||
- Use %VAR% for environment variables.
|
- Use %VAR% for environment variables.
|
||||||
|
|
@ -205,8 +208,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 ${defaultTimeoutMs}ms.
|
- 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.
|
- 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)
|
||||||
|
|
@ -229,7 +231,7 @@ Usage notes:
|
||||||
</bad-example>`
|
</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 isPowerShell = PS.has(name)
|
||||||
const chain = chainGuidance(name)
|
const chain = chainGuidance(name)
|
||||||
if (CMD.has(name)) {
|
if (CMD.has(name)) {
|
||||||
|
|
@ -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)
|
const selected = profile(name, platform, limits, defaultTimeoutMs)
|
||||||
return {
|
return {
|
||||||
description: renderPrompt(DESCRIPTION, {
|
description: renderPrompt(DESCRIPTION, {
|
||||||
|
|
|
||||||
|
|
@ -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 = { maxLines: number; maxBytes: number }
|
||||||
|
|
||||||
export interface Options {
|
export interface Options {
|
||||||
maxLines?: number
|
maxLines?: number
|
||||||
|
|
@ -40,9 +41,11 @@ 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 limits from `tool_output` in opencode config.
|
||||||
|
* Returns `None` when the user has disabled truncation (`tool_output: 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") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Truncate") {}
|
||||||
|
|
@ -75,18 +78,21 @@ 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 Option.some({ 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)))
|
||||||
return {
|
const tool_output = cfg?.tool_output
|
||||||
maxLines: cfg?.tool_output?.max_lines ?? MAX_LINES,
|
if (tool_output === false) return Option.none<Limits>()
|
||||||
maxBytes: cfg?.tool_output?.max_bytes ?? MAX_BYTES,
|
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 output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
|
||||||
const resolved = yield* limits()
|
const resolved = yield* limits()
|
||||||
const maxLines = options.maxLines ?? resolved.maxLines
|
if (Option.isNone(resolved)) return { content: text, truncated: false } as const
|
||||||
const maxBytes = options.maxBytes ?? resolved.maxBytes
|
const maxLines = options.maxLines ?? resolved.value.maxLines
|
||||||
|
const maxBytes = options.maxBytes ?? resolved.value.maxBytes
|
||||||
const direction = options.direction ?? "head"
|
const direction = options.direction ?? "head"
|
||||||
const lines = text.split("\n")
|
const lines = text.split("\n")
|
||||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||||
|
|
|
||||||
|
|
@ -1292,6 +1292,52 @@ test("config parser preserves permission order while rejecting unknown top-level
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("tool_output accepts thresholds or disables truncation", () => {
|
||||||
|
expect(ConfigParse.schema(Config.Info, { tool_output: false }, "test").tool_output).toBe(false)
|
||||||
|
expect(
|
||||||
|
ConfigParse.schema(Config.Info, { tool_output: { max_lines: 200, max_bytes: 8192 } }, "test").tool_output,
|
||||||
|
).toEqual({
|
||||||
|
max_lines: 200,
|
||||||
|
max_bytes: 8192,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.effect("project tool_output limits replace disabled global truncation", () =>
|
||||||
|
withConfigTree(
|
||||||
|
{
|
||||||
|
global: { tool_output: false },
|
||||||
|
project: { tool_output: { max_lines: 200 } },
|
||||||
|
},
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect((yield* Config.use.get()).tool_output).toEqual({ max_lines: 200 })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("project disabled tool_output replaces global limits", () =>
|
||||||
|
withConfigTree(
|
||||||
|
{
|
||||||
|
global: { tool_output: { max_lines: 200, max_bytes: 8192 } },
|
||||||
|
project: { tool_output: false },
|
||||||
|
},
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect((yield* Config.use.get()).tool_output).toBe(false)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("enabled tool_output limits still merge across layers", () =>
|
||||||
|
withConfigTree(
|
||||||
|
{
|
||||||
|
global: { tool_output: { max_bytes: 8192 } },
|
||||||
|
project: { tool_output: { max_lines: 200 } },
|
||||||
|
},
|
||||||
|
Effect.gen(function* () {
|
||||||
|
expect((yield* Config.use.get()).tool_output).toEqual({ max_bytes: 8192, max_lines: 200 })
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// MCP config merging tests
|
// MCP config merging tests
|
||||||
|
|
||||||
it.instance("project config can override MCP server enabled status", () =>
|
it.instance("project config can override MCP server enabled status", () =>
|
||||||
|
|
|
||||||
|
|
@ -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: 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,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, expect } from "bun:test"
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
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 { Truncate } from "@/tool/truncate"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { Identifier } from "../../src/id/id"
|
import { Identifier } from "../../src/id/id"
|
||||||
|
|
@ -110,8 +110,11 @@ 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.maxLines).toBe(Truncate.MAX_LINES)
|
expect(Option.isSome(resolved)).toBe(true)
|
||||||
expect(resolved.maxBytes).toBe(Truncate.MAX_BYTES)
|
if (Option.isSome(resolved)) {
|
||||||
|
expect(resolved.value.maxLines).toBe(Truncate.MAX_LINES)
|
||||||
|
expect(resolved.value.maxBytes).toBe(Truncate.MAX_BYTES)
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -120,8 +123,11 @@ 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.maxLines).toBe(123)
|
expect(Option.isSome(resolved)).toBe(true)
|
||||||
expect(resolved.maxBytes).toBe(456)
|
if (Option.isSome(resolved)) {
|
||||||
|
expect(resolved.value.maxLines).toBe(123)
|
||||||
|
expect(resolved.value.maxBytes).toBe(456)
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -159,6 +165,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(Option.isNone(resolved)).toBe(true)
|
||||||
|
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", () =>
|
||||||
|
|
|
||||||
|
|
@ -1294,10 +1294,15 @@ export type Config = {
|
||||||
enterprise?: {
|
enterprise?: {
|
||||||
url?: string
|
url?: string
|
||||||
}
|
}
|
||||||
tool_output?: {
|
/**
|
||||||
max_lines?: number
|
* Configure tool output truncation. 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
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,36 @@ 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": {
|
||||||
|
"max_lines": 2000,
|
||||||
|
"max_bytes": 51200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `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 `tool_output` to `false`:
|
||||||
|
|
||||||
|
```json title="opencode.json"
|
||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"tool_output": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Models
|
### Models
|
||||||
|
|
||||||
You can configure the providers and models you want to use in your OpenCode config through the `provider`, `model` and `small_model` options.
|
You can configure the providers and models you want to use in your OpenCode config through the `provider`, `model` and `small_model` options.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue