From f939060cd1df3cfa90ec5e84421d2a13f9a4bdd3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 7 Jan 2026 12:15:35 -0600 Subject: [PATCH 01/13] wip --- packages/opencode/src/agent/agent.ts | 6 +- packages/opencode/src/id/id.ts | 1 + packages/opencode/src/session/truncation.ts | 63 ++++++++++++++----- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/tool.ts | 2 +- .../opencode/test/session/truncation.test.ts | 49 ++++++++++----- 6 files changed, 92 insertions(+), 31 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index cc8942c2aef..df641c07fff 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -4,6 +4,7 @@ import { Provider } from "../provider/provider" import { generateObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" +import { Truncate } from "../session/truncation" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" @@ -46,7 +47,10 @@ export namespace Agent { const defaults = PermissionNext.fromConfig({ "*": "allow", doom_loop: "ask", - external_directory: "ask", + external_directory: { + "*": "ask", + [Truncate.DIR]: "allow", + }, // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index ad6e22e1bee..b1c310e4d6b 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -9,6 +9,7 @@ export namespace Identifier { user: "usr", part: "prt", pty: "pty", + tool: "tool", } as const export function schema(prefix: keyof typeof prefixes) { diff --git a/packages/opencode/src/session/truncation.ts b/packages/opencode/src/session/truncation.ts index 15177a55a65..d1bb933403d 100644 --- a/packages/opencode/src/session/truncation.ts +++ b/packages/opencode/src/session/truncation.ts @@ -1,10 +1,20 @@ +import fs from "fs/promises" +import path from "path" +import { Global } from "../global" +import { Identifier } from "../id/id" +import { iife } from "../util/iife" +import { lazy } from "../util/lazy" + export namespace Truncate { export const MAX_LINES = 2000 export const MAX_BYTES = 50 * 1024 + export const DIR = path.join(Global.Path.data, "tool-output") + const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days export interface Result { content: string truncated: boolean + outputPath?: string } export interface Options { @@ -13,7 +23,22 @@ export namespace Truncate { direction?: "head" | "tail" } - export function output(text: string, options: Options = {}): Result { + const init = lazy(async () => { + const cutoff = Date.now() - RETENTION_MS + const entries = await fs.readdir(DIR).catch(() => [] as string[]) + for (const entry of entries) { + if (!entry.startsWith("tool_")) continue + const timestamp = iife(() => { + const hex = entry.slice(5, 17) + const now = BigInt("0x" + hex) + return Number(now / BigInt(0x1000)) + }) + if (timestamp >= cutoff) continue + await fs.rm(path.join(DIR, entry), { force: true }).catch(() => {}) + } + }) + + export async function output(text: string, options: Options = {}): Promise { const maxLines = options.maxLines ?? MAX_LINES const maxBytes = options.maxBytes ?? MAX_BYTES const direction = options.direction ?? "head" @@ -39,22 +64,32 @@ export namespace Truncate { out.push(lines[i]) bytes += size } - const removed = hitBytes ? totalBytes - bytes : lines.length - out.length - const unit = hitBytes ? "chars" : "lines" - return { content: `${out.join("\n")}\n\n...${removed} ${unit} truncated...`, truncated: true } - } - - for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { - const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) - if (bytes + size > maxBytes) { - hitBytes = true - break + } else { + for (i = lines.length - 1; i >= 0 && out.length < maxLines; i--) { + const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0) + if (bytes + size > maxBytes) { + hitBytes = true + break + } + out.unshift(lines[i]) + bytes += size } - out.unshift(lines[i]) - bytes += size } + const removed = hitBytes ? totalBytes - bytes : lines.length - out.length const unit = hitBytes ? "chars" : "lines" - return { content: `...${removed} ${unit} truncated...\n\n${out.join("\n")}`, truncated: true } + const preview = out.join("\n") + + await init() + const id = Identifier.ascending("tool") + const filepath = path.join(DIR, id) + await Bun.write(Bun.file(filepath), text) + + const message = + direction === "head" + ? `${preview}\n\n...${removed} ${unit} truncated...\n\nFull output written to: ${filepath}\nUse Read or Grep to view the full content.` + : `...${removed} ${unit} truncated...\n\nFull output written to: ${filepath}\nUse Read or Grep to view the full content.\n\n${preview}` + + return { content: message, truncated: true, outputPath: filepath } } } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index bca6626db70..657a5b31820 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -65,7 +65,7 @@ export namespace ToolRegistry { description: def.description, execute: async (args, ctx) => { const result = await def.execute(args as any, ctx) - const out = Truncate.output(result) + const out = await Truncate.output(result) return { title: "", output: out.truncated ? out.content : result, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 060da0ae763..94385b0f31c 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -66,7 +66,7 @@ export namespace Tool { ) } const result = await execute(args, ctx) - const truncated = Truncate.output(result.output) + const truncated = await Truncate.output(result.output) return { ...result, output: truncated.content, diff --git a/packages/opencode/test/session/truncation.test.ts b/packages/opencode/test/session/truncation.test.ts index a3891ed450d..e4b952f056c 100644 --- a/packages/opencode/test/session/truncation.test.ts +++ b/packages/opencode/test/session/truncation.test.ts @@ -8,41 +8,40 @@ describe("Truncate", () => { describe("output", () => { test("truncates large json file by bytes", async () => { const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text() - const result = Truncate.output(content) + const result = await Truncate.output(content) expect(result.truncated).toBe(true) - expect(Buffer.byteLength(result.content, "utf-8")).toBeLessThanOrEqual(Truncate.MAX_BYTES + 100) expect(result.content).toContain("truncated...") + expect(result.outputPath).toBeDefined() }) - test("returns content unchanged when under limits", () => { + test("returns content unchanged when under limits", async () => { const content = "line1\nline2\nline3" - const result = Truncate.output(content) + const result = await Truncate.output(content) expect(result.truncated).toBe(false) expect(result.content).toBe(content) }) - test("truncates by line count", () => { + test("truncates by line count", async () => { const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") - const result = Truncate.output(lines, { maxLines: 10 }) + const result = await Truncate.output(lines, { maxLines: 10 }) expect(result.truncated).toBe(true) - expect(result.content.split("\n").length).toBeLessThanOrEqual(12) expect(result.content).toContain("...90 lines truncated...") }) - test("truncates by byte count", () => { + test("truncates by byte count", async () => { const content = "a".repeat(1000) - const result = Truncate.output(content, { maxBytes: 100 }) + const result = await Truncate.output(content, { maxBytes: 100 }) expect(result.truncated).toBe(true) expect(result.content).toContain("truncated...") }) - test("truncates from head by default", () => { + test("truncates from head by default", async () => { const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") - const result = Truncate.output(lines, { maxLines: 3 }) + const result = await Truncate.output(lines, { maxLines: 3 }) expect(result.truncated).toBe(true) expect(result.content).toContain("line0") @@ -51,9 +50,9 @@ describe("Truncate", () => { expect(result.content).not.toContain("line9") }) - test("truncates from tail when direction is tail", () => { + test("truncates from tail when direction is tail", async () => { const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n") - const result = Truncate.output(lines, { maxLines: 3, direction: "tail" }) + const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" }) expect(result.truncated).toBe(true) expect(result.content).toContain("line7") @@ -69,11 +68,33 @@ describe("Truncate", () => { test("large single-line file truncates with byte message", async () => { const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text() - const result = Truncate.output(content) + const result = await Truncate.output(content) expect(result.truncated).toBe(true) expect(result.content).toContain("chars truncated...") expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES) }) + + test("writes full output to file when truncated", async () => { + const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") + const result = await Truncate.output(lines, { maxLines: 10 }) + + expect(result.truncated).toBe(true) + expect(result.outputPath).toBeDefined() + expect(result.outputPath).toContain("tool_") + expect(result.content).toContain("Full output written to:") + expect(result.content).toContain("Use Read or Grep to view the full content") + + const written = await Bun.file(result.outputPath!).text() + expect(written).toBe(lines) + }) + + test("does not write file when not truncated", async () => { + const content = "short content" + const result = await Truncate.output(content) + + expect(result.truncated).toBe(false) + expect(result.outputPath).toBeUndefined() + }) }) }) From ece719dc77aad461bda8eebf741a310d39f3a373 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 7 Jan 2026 12:54:13 -0600 Subject: [PATCH 02/13] wip --- packages/opencode/src/session/truncation.ts | 17 +++++++++++--- packages/opencode/src/tool/registry.ts | 4 ++-- packages/opencode/src/tool/tool.ts | 6 ++--- .../opencode/test/session/truncation.test.ts | 22 ++++++++++++++++++- 4 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/truncation.ts b/packages/opencode/src/session/truncation.ts index d1bb933403d..471c86b57c8 100644 --- a/packages/opencode/src/session/truncation.ts +++ b/packages/opencode/src/session/truncation.ts @@ -4,7 +4,10 @@ import { Global } from "../global" import { Identifier } from "../id/id" import { iife } from "../util/iife" import { lazy } from "../util/lazy" +import { PermissionNext } from "../permission/next" +import type { Agent } from "../agent/agent" +// what models does opencode provider support? Read: https://models.dev/api.json export namespace Truncate { export const MAX_LINES = 2000 export const MAX_BYTES = 50 * 1024 @@ -38,7 +41,13 @@ export namespace Truncate { } }) - export async function output(text: string, options: Options = {}): Promise { + function hasTaskTool(agent?: Agent.Info): boolean { + if (!agent?.permission) return false + const rule = PermissionNext.evaluate("task", "*", agent.permission) + return rule.action !== "deny" + } + + export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise { const maxLines = options.maxLines ?? MAX_LINES const maxBytes = options.maxBytes ?? MAX_BYTES const direction = options.direction ?? "head" @@ -85,10 +94,12 @@ export namespace Truncate { const filepath = path.join(DIR, id) await Bun.write(Bun.file(filepath), text) + const base = `Full output written to: ${filepath}\nUse Grep to search the full content and Read with offset/limit to read specific sections` + const hint = hasTaskTool(agent) ? `${base} (or use Task tool to delegate and save context).` : `${base}.` const message = direction === "head" - ? `${preview}\n\n...${removed} ${unit} truncated...\n\nFull output written to: ${filepath}\nUse Read or Grep to view the full content.` - : `...${removed} ${unit} truncated...\n\nFull output written to: ${filepath}\nUse Read or Grep to view the full content.\n\n${preview}` + ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` + : `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}` return { content: message, truncated: true, outputPath: filepath } } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 657a5b31820..96432369f64 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -60,12 +60,12 @@ export namespace ToolRegistry { function fromPlugin(id: string, def: ToolDefinition): Tool.Info { return { id, - init: async () => ({ + init: async (initCtx) => ({ parameters: z.object(def.args), description: def.description, execute: async (args, ctx) => { const result = await def.execute(args as any, ctx) - const out = await Truncate.output(result) + const out = await Truncate.output(result, {}, initCtx?.agent) return { title: "", output: out.truncated ? out.content : result, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 94385b0f31c..7545d36b1aa 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -50,8 +50,8 @@ export namespace Tool { ): Info { return { id, - init: async (ctx) => { - const toolInfo = init instanceof Function ? await init(ctx) : init + init: async (initCtx) => { + const toolInfo = init instanceof Function ? await init(initCtx) : init const execute = toolInfo.execute toolInfo.execute = async (args, ctx) => { try { @@ -66,7 +66,7 @@ export namespace Tool { ) } const result = await execute(args, ctx) - const truncated = await Truncate.output(result.output) + const truncated = await Truncate.output(result.output, {}, initCtx?.agent) return { ...result, output: truncated.content, diff --git a/packages/opencode/test/session/truncation.test.ts b/packages/opencode/test/session/truncation.test.ts index e4b952f056c..242109f5c34 100644 --- a/packages/opencode/test/session/truncation.test.ts +++ b/packages/opencode/test/session/truncation.test.ts @@ -83,12 +83,32 @@ describe("Truncate", () => { expect(result.outputPath).toBeDefined() expect(result.outputPath).toContain("tool_") expect(result.content).toContain("Full output written to:") - expect(result.content).toContain("Use Read or Grep to view the full content") + expect(result.content).toContain("Grep") const written = await Bun.file(result.outputPath!).text() expect(written).toBe(lines) }) + test("suggests Task tool when agent has task permission", async () => { + const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") + const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] } + const result = await Truncate.output(lines, { maxLines: 10 }, agent as any) + + expect(result.truncated).toBe(true) + expect(result.content).toContain("Grep") + expect(result.content).toContain("Task tool") + }) + + test("omits Task tool hint when agent lacks task permission", async () => { + const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") + const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] } + const result = await Truncate.output(lines, { maxLines: 10 }, agent as any) + + expect(result.truncated).toBe(true) + expect(result.content).toContain("Grep") + expect(result.content).not.toContain("Task tool") + }) + test("does not write file when not truncated", async () => { const content = "short content" const result = await Truncate.output(content) From c47b976e67af5abdfbd3b881f58dc70b29fc2686 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 7 Jan 2026 13:19:36 -0600 Subject: [PATCH 03/13] wip --- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/tool/read.ts | 28 +++- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/tool.ts | 6 +- .../src/{session => tool}/truncation.ts | 0 .../fixtures/models-api.json | 0 packages/opencode/test/tool/read.test.ts | 122 ++++++++++++++++++ .../test/{session => tool}/truncation.test.ts | 2 +- 8 files changed, 153 insertions(+), 9 deletions(-) rename packages/opencode/src/{session => tool}/truncation.ts (100%) rename packages/opencode/test/{session => tool}/fixtures/models-api.json (100%) rename packages/opencode/test/{session => tool}/truncation.test.ts (98%) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index df641c07fff..6211e667bb2 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -4,7 +4,7 @@ import { Provider } from "../provider/provider" import { generateObject, type ModelMessage } from "ai" import { SystemPrompt } from "../session/system" import { Instance } from "../project/instance" -import { Truncate } from "../session/truncation" +import { Truncate } from "../tool/truncation" import PROMPT_GENERATE from "./generate.txt" import PROMPT_COMPACTION from "./prompt/compaction.txt" diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index a0f50129e50..56742517483 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -11,6 +11,7 @@ import { Identifier } from "../id/id" const DEFAULT_READ_LIMIT = 2000 const MAX_LINE_LENGTH = 2000 +const MAX_BYTES = 50 * 1024 export const ReadTool = Tool.define("read", { description: DESCRIPTION, @@ -77,6 +78,7 @@ export const ReadTool = Tool.define("read", { output: msg, metadata: { preview: msg, + truncated: false, }, attachments: [ { @@ -97,9 +99,21 @@ export const ReadTool = Tool.define("read", { const limit = params.limit ?? DEFAULT_READ_LIMIT const offset = params.offset || 0 const lines = await file.text().then((text) => text.split("\n")) - const raw = lines.slice(offset, offset + limit).map((line) => { - return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line - }) + + const raw: string[] = [] + let bytes = 0 + let truncatedByBytes = false + for (let i = offset; i < Math.min(lines.length, offset + limit); i++) { + const line = lines[i].length > MAX_LINE_LENGTH ? lines[i].substring(0, MAX_LINE_LENGTH) + "..." : lines[i] + const size = Buffer.byteLength(line, "utf-8") + (raw.length > 0 ? 1 : 0) + if (bytes + size > MAX_BYTES) { + truncatedByBytes = true + break + } + raw.push(line) + bytes += size + } + const content = raw.map((line, index) => { return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}` }) @@ -109,10 +123,13 @@ export const ReadTool = Tool.define("read", { output += content.join("\n") const totalLines = lines.length - const lastReadLine = offset + content.length + const lastReadLine = offset + raw.length const hasMoreLines = totalLines > lastReadLine + const truncated = hasMoreLines || truncatedByBytes - if (hasMoreLines) { + if (truncatedByBytes) { + output += `\n\n(Output truncated at ${MAX_BYTES} bytes. Use 'offset' parameter to read beyond line ${lastReadLine})` + } else if (hasMoreLines) { output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})` } else { output += `\n\n(End of file - total ${totalLines} lines)` @@ -128,6 +145,7 @@ export const ReadTool = Tool.define("read", { output, metadata: { preview, + truncated, }, } }, diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 96432369f64..af9a896c642 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -23,7 +23,7 @@ import { CodeSearchTool } from "./codesearch" import { Flag } from "@/flag/flag" import { Log } from "@/util/log" import { LspTool } from "./lsp" -import { Truncate } from "../session/truncation" +import { Truncate } from "./truncation" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 7545d36b1aa..113a7a3c079 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -2,7 +2,7 @@ import z from "zod" import type { MessageV2 } from "../session/message-v2" import type { Agent } from "../agent/agent" import type { PermissionNext } from "../permission/next" -import { Truncate } from "../session/truncation" +import { Truncate } from "./truncation" export namespace Tool { interface Metadata { @@ -66,6 +66,10 @@ export namespace Tool { ) } const result = await execute(args, ctx) + // skip truncation for tools that handle it themselves + if (result.metadata.truncated !== undefined) { + return result + } const truncated = await Truncate.output(result.output, {}, initCtx?.agent) return { ...result, diff --git a/packages/opencode/src/session/truncation.ts b/packages/opencode/src/tool/truncation.ts similarity index 100% rename from packages/opencode/src/session/truncation.ts rename to packages/opencode/src/tool/truncation.ts diff --git a/packages/opencode/test/session/fixtures/models-api.json b/packages/opencode/test/tool/fixtures/models-api.json similarity index 100% rename from packages/opencode/test/session/fixtures/models-api.json rename to packages/opencode/test/tool/fixtures/models-api.json diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 826fa03f6ca..a88d25f73ab 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -6,6 +6,8 @@ import { tmpdir } from "../fixture/fixture" import { PermissionNext } from "../../src/permission/next" import { Agent } from "../../src/agent/agent" +const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") + const ctx = { sessionID: "test", messageID: "", @@ -165,3 +167,123 @@ describe("tool.read env file blocking", () => { }) }) }) + +describe("tool.read truncation", () => { + test("truncates large file by bytes and sets truncated metadata", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const content = await Bun.file(path.join(FIXTURES_DIR, "models-api.json")).text() + await Bun.write(path.join(dir, "large.json"), content) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "large.json") }, ctx) + expect(result.metadata.truncated).toBe(true) + expect(result.output).toContain("Output truncated at") + expect(result.output).toContain("bytes") + }, + }) + }) + + test("truncates by line count when limit is specified", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n") + await Bun.write(path.join(dir, "many-lines.txt"), lines) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "many-lines.txt"), limit: 10 }, ctx) + expect(result.metadata.truncated).toBe(true) + expect(result.output).toContain("File has more lines") + expect(result.output).toContain("line0") + expect(result.output).toContain("line9") + expect(result.output).not.toContain("line10") + }, + }) + }) + + test("does not truncate small file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "small.txt"), "hello world") + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "small.txt") }, ctx) + expect(result.metadata.truncated).toBe(false) + expect(result.output).toContain("End of file") + }, + }) + }) + + test("respects offset parameter", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const lines = Array.from({ length: 20 }, (_, i) => `line${i}`).join("\n") + await Bun.write(path.join(dir, "offset.txt"), lines) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx) + expect(result.output).toContain("line10") + expect(result.output).toContain("line14") + expect(result.output).not.toContain("line0") + expect(result.output).not.toContain("line15") + }, + }) + }) + + test("truncates long lines", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const longLine = "x".repeat(3000) + await Bun.write(path.join(dir, "long-line.txt"), longLine) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "long-line.txt") }, ctx) + expect(result.output).toContain("...") + expect(result.output.length).toBeLessThan(3000) + }, + }) + }) + + test("image files set truncated to false", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // 1x1 red PNG + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==", + "base64", + ) + await Bun.write(path.join(dir, "image.png"), png) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const read = await ReadTool.init() + const result = await read.execute({ filePath: path.join(tmp.path, "image.png") }, ctx) + expect(result.metadata.truncated).toBe(false) + expect(result.attachments).toBeDefined() + expect(result.attachments?.length).toBe(1) + }, + }) + }) +}) diff --git a/packages/opencode/test/session/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts similarity index 98% rename from packages/opencode/test/session/truncation.test.ts rename to packages/opencode/test/tool/truncation.test.ts index 242109f5c34..9560f538918 100644 --- a/packages/opencode/test/session/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test" -import { Truncate } from "../../src/session/truncation" +import { Truncate } from "../../src/tool/truncation" import path from "path" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") From 2e0c43966cc96e2535e51565916972100850d3dd Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 7 Jan 2026 13:44:29 -0600 Subject: [PATCH 04/13] wip --- packages/opencode/src/tool/bash.ts | 29 +++----- packages/opencode/test/tool/bash.test.ts | 89 ++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index b9e0f8a1c3e..52aaf7e1bc9 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -16,7 +16,6 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" -const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 export const log = Log.create({ service: "bash-tool" }) @@ -172,15 +171,13 @@ export const BashTool = Tool.define("bash", async () => { }) const append = (chunk: Buffer) => { - if (output.length <= MAX_OUTPUT_LENGTH) { - output += chunk.toString() - ctx.metadata({ - metadata: { - output, - description: params.description, - }, - }) - } + output += chunk.toString() + ctx.metadata({ + metadata: { + output, + description: params.description, + }, + }) } proc.stdout?.on("data", append) @@ -228,12 +225,7 @@ export const BashTool = Tool.define("bash", async () => { }) }) - let resultMetadata: String[] = [""] - - if (output.length > MAX_OUTPUT_LENGTH) { - output = output.slice(0, MAX_OUTPUT_LENGTH) - resultMetadata.push(`bash tool truncated output as it exceeded ${MAX_OUTPUT_LENGTH} char limit`) - } + const resultMetadata: string[] = [] if (timedOut) { resultMetadata.push(`bash tool terminated command after exceeding timeout ${timeout} ms`) @@ -243,9 +235,8 @@ export const BashTool = Tool.define("bash", async () => { resultMetadata.push("User aborted the command") } - if (resultMetadata.length > 1) { - resultMetadata.push("") - output += "\n\n" + resultMetadata.join("\n") + if (resultMetadata.length > 0) { + output += "\n\n\n" + resultMetadata.join("\n") + "\n" } return { diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 2eb17a9fc94..b58858f11d2 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -4,6 +4,7 @@ import { BashTool } from "../../src/tool/bash" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" import type { PermissionNext } from "../../src/permission/next" +import { Truncate } from "../../src/tool/truncation" const ctx = { sessionID: "test", @@ -230,3 +231,91 @@ describe("tool.bash permissions", () => { }) }) }) + +describe("tool.bash truncation", () => { + test("truncates output exceeding line limit", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const lineCount = Truncate.MAX_LINES + 500 + const result = await bash.execute( + { + command: `seq 1 ${lineCount}`, + description: "Generate lines exceeding limit", + }, + ctx, + ) + expect((result.metadata as any).truncated).toBe(true) + expect(result.output).toContain("truncated") + expect(result.output).toContain("Full output written to:") + }, + }) + }) + + test("truncates output exceeding byte limit", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const byteCount = Truncate.MAX_BYTES + 10000 + const result = await bash.execute( + { + command: `head -c ${byteCount} /dev/zero | tr '\\0' 'a'`, + description: "Generate bytes exceeding limit", + }, + ctx, + ) + expect((result.metadata as any).truncated).toBe(true) + expect(result.output).toContain("truncated") + expect(result.output).toContain("Full output written to:") + }, + }) + }) + + test("does not truncate small output", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const result = await bash.execute( + { + command: "echo hello", + description: "Echo hello", + }, + ctx, + ) + expect((result.metadata as any).truncated).toBe(false) + expect(result.output).toBe("hello\n") + }, + }) + }) + + test("full output is saved to file when truncated", async () => { + await Instance.provide({ + directory: projectRoot, + fn: async () => { + const bash = await BashTool.init() + const lineCount = Truncate.MAX_LINES + 100 + const result = await bash.execute( + { + command: `seq 1 ${lineCount}`, + description: "Generate lines for file check", + }, + ctx, + ) + expect((result.metadata as any).truncated).toBe(true) + + const match = result.output.match(/Full output written to: (.+)/) + expect(match).toBeTruthy() + + const filepath = match![1].split("\n")[0] + const saved = await Bun.file(filepath).text() + const lines = saved.trim().split("\n") + expect(lines.length).toBe(lineCount) + expect(lines[0]).toBe("1") + expect(lines[lineCount - 1]).toBe(String(lineCount)) + }, + }) + }) +}) From df4a973891fc2ea7b54fd2adaa3b0d3beac888f0 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 7 Jan 2026 14:08:48 -0600 Subject: [PATCH 05/13] chore: cleanup --- packages/opencode/src/tool/truncation.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index 471c86b57c8..3dc5a5e8a00 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -7,7 +7,6 @@ import { lazy } from "../util/lazy" import { PermissionNext } from "../permission/next" import type { Agent } from "../agent/agent" -// what models does opencode provider support? Read: https://models.dev/api.json export namespace Truncate { export const MAX_LINES = 2000 export const MAX_BYTES = 50 * 1024 @@ -59,9 +58,9 @@ export namespace Truncate { } const out: string[] = [] - var i = 0 - var bytes = 0 - var hitBytes = false + let i = 0 + let bytes = 0 + let hitBytes = false if (direction === "head") { for (i = 0; i < lines.length && i < maxLines; i++) { From f82f9221e6c68c2d082c6b29bfa6df406dbb1ddd Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 7 Jan 2026 15:04:29 -0600 Subject: [PATCH 06/13] core: improve file cleanup by extracting timestamp logic from ID and using Bun.Glob for efficient file scanning --- packages/opencode/src/id/id.ts | 8 ++++ packages/opencode/src/tool/truncation.ts | 22 +++++------ .../opencode/test/tool/truncation.test.ts | 39 ++++++++++++++++++- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index b1c310e4d6b..7c81c5ed62d 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -71,4 +71,12 @@ export namespace Identifier { return prefixes[prefix] + "_" + timeBytes.toString("hex") + randomBase62(LENGTH - 12) } + + /** Extract timestamp from an ascending ID. Does not work with descending IDs. */ + export function timestamp(id: string): number { + const prefix = id.split("_")[0] + const hex = id.slice(prefix.length + 1, prefix.length + 13) + const encoded = BigInt("0x" + hex) + return Number(encoded / BigInt(0x1000)) + } } diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index 3dc5a5e8a00..628685a0b60 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -2,7 +2,6 @@ import fs from "fs/promises" import path from "path" import { Global } from "../global" import { Identifier } from "../id/id" -import { iife } from "../util/iife" import { lazy } from "../util/lazy" import { PermissionNext } from "../permission/next" import type { Agent } from "../agent/agent" @@ -25,20 +24,17 @@ export namespace Truncate { direction?: "head" | "tail" } - const init = lazy(async () => { - const cutoff = Date.now() - RETENTION_MS - const entries = await fs.readdir(DIR).catch(() => [] as string[]) + export async function cleanup() { + const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS)) + const glob = new Bun.Glob("tool_*") + const entries = await Array.fromAsync(glob.scan({ cwd: DIR, onlyFiles: true })).catch(() => [] as string[]) for (const entry of entries) { - if (!entry.startsWith("tool_")) continue - const timestamp = iife(() => { - const hex = entry.slice(5, 17) - const now = BigInt("0x" + hex) - return Number(now / BigInt(0x1000)) - }) - if (timestamp >= cutoff) continue - await fs.rm(path.join(DIR, entry), { force: true }).catch(() => {}) + if (Identifier.timestamp(entry) >= cutoff) continue + await fs.unlink(path.join(DIR, entry)).catch(() => {}) } - }) + } + + const init = lazy(cleanup) function hasTaskTool(agent?: Agent.Info): boolean { if (!agent?.permission) return false diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 9560f538918..bb6a43b70fc 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -1,5 +1,7 @@ -import { describe, test, expect } from "bun:test" +import { describe, test, expect, afterAll } from "bun:test" import { Truncate } from "../../src/tool/truncation" +import { Identifier } from "../../src/id/id" +import fs from "fs/promises" import path from "path" const FIXTURES_DIR = path.join(import.meta.dir, "fixtures") @@ -117,4 +119,39 @@ describe("Truncate", () => { expect(result.outputPath).toBeUndefined() }) }) + + describe("cleanup", () => { + const DAY_MS = 24 * 60 * 60 * 1000 + let oldFile: string + let recentFile: string + + afterAll(async () => { + await fs.unlink(oldFile).catch(() => {}) + await fs.unlink(recentFile).catch(() => {}) + }) + + test("deletes files older than 7 days and preserves recent files", async () => { + await fs.mkdir(Truncate.DIR, { recursive: true }) + + // Create an old file (10 days ago) + const oldTimestamp = Date.now() - 10 * DAY_MS + const oldId = Identifier.create("tool", false, oldTimestamp) + oldFile = path.join(Truncate.DIR, oldId) + await Bun.write(Bun.file(oldFile), "old content") + + // Create a recent file (3 days ago) + const recentTimestamp = Date.now() - 3 * DAY_MS + const recentId = Identifier.create("tool", false, recentTimestamp) + recentFile = path.join(Truncate.DIR, recentId) + await Bun.write(Bun.file(recentFile), "recent content") + + await Truncate.cleanup() + + // Old file should be deleted + expect(await Bun.file(oldFile).exists()).toBe(false) + + // Recent file should still exist + expect(await Bun.file(recentFile).exists()).toBe(true) + }) + }) }) From 257a486d38fdf62c80394dab2e128fd29660cb31 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 7 Jan 2026 15:40:14 -0600 Subject: [PATCH 07/13] wip --- packages/opencode/src/agent/agent.ts | 3 +++ packages/opencode/src/tool/bash.ts | 5 ++++- packages/opencode/src/tool/bash.txt | 5 ++--- packages/opencode/src/tool/truncation.ts | 5 +++-- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 73c2e80cda9..5d558ea14ca 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -114,6 +114,9 @@ export namespace Agent { websearch: "allow", codesearch: "allow", read: "allow", + external_directory: { + [Truncate.DIR]: "allow", + }, }), user, ), diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 52aaf7e1bc9..0ead77f080a 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -15,6 +15,7 @@ import { Flag } from "@/flag/flag.ts" import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" +import { Truncate } from "./truncation" const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 @@ -54,7 +55,9 @@ export const BashTool = Tool.define("bash", async () => { log.info("bash tool using shell", { shell }) return { - description: DESCRIPTION.replaceAll("${directory}", Instance.directory), + description: DESCRIPTION.replaceAll("${directory}", Instance.directory) + .replaceAll("${maxLines}", String(Truncate.MAX_LINES)) + .replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)), parameters: z.object({ command: z.string().describe("The command to execute"), timeout: z.number().describe("Optional timeout in milliseconds").optional(), diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index c31263c04eb..9fbc9fcf37e 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -22,10 +22,9 @@ Before executing the command, please follow these steps: Usage notes: - The command argument is required. - - You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). 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. - - If the output exceeds 30000 characters, output will be truncated before being returned to you. - - You can use the `run_in_background` parameter to run the command in the background, which allows you to continue working while the command runs. You can monitor the output using the Bash tool as it becomes available. You do not need to use '&' at the end of the command when using this parameter. + - If the output exceeds ${maxLines} lines or ${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. Because of this, you do NOT need to use `head`, `tail`, or other truncation commands to limit output - just run the command directly. - 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) diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index 628685a0b60..c6c08e08574 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -89,8 +89,9 @@ export namespace Truncate { const filepath = path.join(DIR, id) await Bun.write(Bun.file(filepath), text) - const base = `Full output written to: ${filepath}\nUse Grep to search the full content and Read with offset/limit to read specific sections` - const hint = hasTaskTool(agent) ? `${base} (or use Task tool to delegate and save context).` : `${base}.` + const hint = hasTaskTool(agent) + ? `Full output written to: ${filepath} (read-only)\nIMPORTANT: Use the Task tool to have a subagent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` + : `Full output written to: ${filepath} (read-only)\nUse Grep to search the full content or Read with offset/limit to read specific sections.` const message = direction === "head" ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` From 0960e4fe0f382d51efe84d7f0c64fa20006c59ca Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 7 Jan 2026 16:21:43 -0600 Subject: [PATCH 08/13] core: expose truncated output file path in tool metadata for easier access to full command results --- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/tool.ts | 1 + packages/opencode/test/tool/bash.test.ts | 5 ++--- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index af9a896c642..ebacc6be3f6 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -69,7 +69,7 @@ export namespace ToolRegistry { return { title: "", output: out.truncated ? out.content : result, - metadata: { truncated: out.truncated }, + metadata: { truncated: out.truncated, outputPath: out.outputPath }, } }, }), diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 113a7a3c079..5b6aa18a668 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -77,6 +77,7 @@ export namespace Tool { metadata: { ...result.metadata, truncated: truncated.truncated, + outputPath: truncated.outputPath, }, } } diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index b58858f11d2..71c61572bef 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -306,10 +306,9 @@ describe("tool.bash truncation", () => { ) expect((result.metadata as any).truncated).toBe(true) - const match = result.output.match(/Full output written to: (.+)/) - expect(match).toBeTruthy() + const filepath = (result.metadata as any).outputPath + expect(filepath).toBeTruthy() - const filepath = match![1].split("\n")[0] const saved = await Bun.file(filepath).text() const lines = saved.trim().split("\n") expect(lines.length).toBe(lineCount) From 8c076ef959623ba4c36c662f19954f7250c86e4a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 7 Jan 2026 16:35:50 -0600 Subject: [PATCH 09/13] tweak: wording --- packages/opencode/src/tool/truncation.ts | 2 +- packages/opencode/test/tool/truncation.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index c6c08e08574..c662f7cc2e2 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -81,7 +81,7 @@ export namespace Truncate { } const removed = hitBytes ? totalBytes - bytes : lines.length - out.length - const unit = hitBytes ? "chars" : "lines" + const unit = hitBytes ? "bytes" : "lines" const preview = out.join("\n") await init() diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index bb6a43b70fc..8227fdc255a 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -73,7 +73,7 @@ describe("Truncate", () => { const result = await Truncate.output(content) expect(result.truncated).toBe(true) - expect(result.content).toContain("chars truncated...") + expect(result.content).toContain("bytes truncated...") expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES) }) From c39cebe97120529c982bc7942910c9904b7ee124 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 7 Jan 2026 16:40:19 -0600 Subject: [PATCH 10/13] tweak wording --- packages/opencode/src/tool/truncation.ts | 4 ++-- packages/opencode/test/tool/bash.test.ts | 4 ++-- packages/opencode/test/tool/truncation.test.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index c662f7cc2e2..48b002b7368 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -90,8 +90,8 @@ export namespace Truncate { await Bun.write(Bun.file(filepath), text) const hint = hasTaskTool(agent) - ? `Full output written to: ${filepath} (read-only)\nIMPORTANT: Use the Task tool to have a subagent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` - : `Full output written to: ${filepath} (read-only)\nUse Grep to search the full content or Read with offset/limit to read specific sections.` + ? `The tool output was too large, the full output was written to the following file: ${filepath}\nIMPORTANT: Use the Task tool to have a subagent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` + : `The tool output was too large, the full output was written to the following file: ${filepath}\nUse Grep to search the full content or Read with offset/limit to read specific sections.` const message = direction === "head" ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 71c61572bef..67306f640ae 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -248,7 +248,7 @@ describe("tool.bash truncation", () => { ) expect((result.metadata as any).truncated).toBe(true) expect(result.output).toContain("truncated") - expect(result.output).toContain("Full output written to:") + expect(result.output).toContain("The tool output was too large") }, }) }) @@ -268,7 +268,7 @@ describe("tool.bash truncation", () => { ) expect((result.metadata as any).truncated).toBe(true) expect(result.output).toContain("truncated") - expect(result.output).toContain("Full output written to:") + expect(result.output).toContain("The tool output was too large") }, }) }) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index 8227fdc255a..fc95f4271bc 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -84,7 +84,7 @@ describe("Truncate", () => { expect(result.truncated).toBe(true) expect(result.outputPath).toBeDefined() expect(result.outputPath).toContain("tool_") - expect(result.content).toContain("Full output written to:") + expect(result.content).toContain("The tool output was too large") expect(result.content).toContain("Grep") const written = await Bun.file(result.outputPath!).text() From 68219475c06160783c66b62b9d2caa6206ba9094 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 7 Jan 2026 17:05:15 -0600 Subject: [PATCH 11/13] tweak: bash metadata context truncation and truncation prompts --- packages/opencode/src/tool/bash.ts | 5 +++-- packages/opencode/src/tool/truncation.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 0ead77f080a..4a4f1cf557b 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -17,6 +17,7 @@ import { Shell } from "@/shell/shell" import { BashArity } from "@/permission/arity" import { Truncate } from "./truncation" +const MAX_METADATA_LENGTH = 30_000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 export const log = Log.create({ service: "bash-tool" }) @@ -177,7 +178,7 @@ export const BashTool = Tool.define("bash", async () => { output += chunk.toString() ctx.metadata({ metadata: { - output, + output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, description: params.description, }, }) @@ -245,7 +246,7 @@ export const BashTool = Tool.define("bash", async () => { return { title: params.description, metadata: { - output, + output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, exit: proc.exitCode, description: params.description, }, diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index 48b002b7368..f82d0cd60c3 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -90,8 +90,8 @@ export namespace Truncate { await Bun.write(Bun.file(filepath), text) const hint = hasTaskTool(agent) - ? `The tool output was too large, the full output was written to the following file: ${filepath}\nIMPORTANT: Use the Task tool to have a subagent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` - : `The tool output was too large, the full output was written to the following file: ${filepath}\nUse Grep to search the full content or Read with offset/limit to read specific sections.` + ? `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse the Task tool to have a subagent process this file with Grep and Read (with offset/limit). Do NOT read the full file yourself - delegate to save context.` + : `The tool call succeeded but the output was truncated. Full output saved to: ${filepath}\nUse Grep to search the full content or Read with offset/limit to view specific sections.` const message = direction === "head" ? `${preview}\n\n...${removed} ${unit} truncated...\n\n${hint}` From eb8c34735f7d8ee7cd3576f3485cc4b07d37cdc5 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 7 Jan 2026 17:11:57 -0600 Subject: [PATCH 12/13] type union --- packages/opencode/src/tool/registry.ts | 2 +- packages/opencode/src/tool/tool.ts | 2 +- packages/opencode/src/tool/truncation.ts | 6 +----- packages/opencode/test/tool/bash.test.ts | 4 ++-- packages/opencode/test/tool/truncation.test.ts | 12 +++++++----- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index ebacc6be3f6..608edc65eb4 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -69,7 +69,7 @@ export namespace ToolRegistry { return { title: "", output: out.truncated ? out.content : result, - metadata: { truncated: out.truncated, outputPath: out.outputPath }, + metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined }, } }, }), diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 5b6aa18a668..78ab325af41 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -77,7 +77,7 @@ export namespace Tool { metadata: { ...result.metadata, truncated: truncated.truncated, - outputPath: truncated.outputPath, + ...(truncated.truncated && { outputPath: truncated.outputPath }), }, } } diff --git a/packages/opencode/src/tool/truncation.ts b/packages/opencode/src/tool/truncation.ts index f82d0cd60c3..133c57d3d1f 100644 --- a/packages/opencode/src/tool/truncation.ts +++ b/packages/opencode/src/tool/truncation.ts @@ -12,11 +12,7 @@ export namespace Truncate { export const DIR = path.join(Global.Path.data, "tool-output") const RETENTION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days - export interface Result { - content: string - truncated: boolean - outputPath?: string - } + export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string } export interface Options { maxLines?: number diff --git a/packages/opencode/test/tool/bash.test.ts b/packages/opencode/test/tool/bash.test.ts index 67306f640ae..750ff8193e9 100644 --- a/packages/opencode/test/tool/bash.test.ts +++ b/packages/opencode/test/tool/bash.test.ts @@ -248,7 +248,7 @@ describe("tool.bash truncation", () => { ) expect((result.metadata as any).truncated).toBe(true) expect(result.output).toContain("truncated") - expect(result.output).toContain("The tool output was too large") + expect(result.output).toContain("The tool call succeeded but the output was truncated") }, }) }) @@ -268,7 +268,7 @@ describe("tool.bash truncation", () => { ) expect((result.metadata as any).truncated).toBe(true) expect(result.output).toContain("truncated") - expect(result.output).toContain("The tool output was too large") + expect(result.output).toContain("The tool call succeeded but the output was truncated") }, }) }) diff --git a/packages/opencode/test/tool/truncation.test.ts b/packages/opencode/test/tool/truncation.test.ts index fc95f4271bc..09222f279fa 100644 --- a/packages/opencode/test/tool/truncation.test.ts +++ b/packages/opencode/test/tool/truncation.test.ts @@ -14,7 +14,7 @@ describe("Truncate", () => { expect(result.truncated).toBe(true) expect(result.content).toContain("truncated...") - expect(result.outputPath).toBeDefined() + if (result.truncated) expect(result.outputPath).toBeDefined() }) test("returns content unchanged when under limits", async () => { @@ -82,12 +82,13 @@ describe("Truncate", () => { const result = await Truncate.output(lines, { maxLines: 10 }) expect(result.truncated).toBe(true) + expect(result.content).toContain("The tool call succeeded but the output was truncated") + expect(result.content).toContain("Grep") + if (!result.truncated) throw new Error("expected truncated") expect(result.outputPath).toBeDefined() expect(result.outputPath).toContain("tool_") - expect(result.content).toContain("The tool output was too large") - expect(result.content).toContain("Grep") - const written = await Bun.file(result.outputPath!).text() + const written = await Bun.file(result.outputPath).text() expect(written).toBe(lines) }) @@ -116,7 +117,8 @@ describe("Truncate", () => { const result = await Truncate.output(content) expect(result.truncated).toBe(false) - expect(result.outputPath).toBeUndefined() + if (result.truncated) throw new Error("expected not truncated") + expect("outputPath" in result).toBe(false) }) }) From 5730703d1124134526a17f930169ddf18d8bda10 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 7 Jan 2026 17:19:20 -0600 Subject: [PATCH 13/13] add comment --- packages/opencode/src/tool/bash.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 4a4f1cf557b..e06a3f157cb 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -178,6 +178,7 @@ export const BashTool = Tool.define("bash", async () => { output += chunk.toString() ctx.metadata({ metadata: { + // truncate the metadata to avoid GIANT blobs of data (has nothing to do w/ what agent can access) output: output.length > MAX_METADATA_LENGTH ? output.slice(0, MAX_METADATA_LENGTH) + "\n\n..." : output, description: params.description, },