Skip to content
Merged
6 changes: 5 additions & 1 deletion packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 "../tool/truncation"

import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/id/id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export namespace Identifier {
user: "usr",
part: "prt",
pty: "pty",
tool: "tool",
} as const

export function schema(prefix: keyof typeof prefixes) {
Expand Down
60 changes: 0 additions & 60 deletions packages/opencode/src/session/truncation.ts

This file was deleted.

29 changes: 10 additions & 19 deletions packages/opencode/src/tool/bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -228,12 +225,7 @@ export const BashTool = Tool.define("bash", async () => {
})
})

let resultMetadata: String[] = ["<bash_metadata>"]

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`)
Expand All @@ -243,9 +235,8 @@ export const BashTool = Tool.define("bash", async () => {
resultMetadata.push("User aborted the command")
}

if (resultMetadata.length > 1) {
resultMetadata.push("</bash_metadata>")
output += "\n\n" + resultMetadata.join("\n")
if (resultMetadata.length > 0) {
output += "\n\n<bash_metadata>\n" + resultMetadata.join("\n") + "\n</bash_metadata>"
}

return {
Expand Down
28 changes: 23 additions & 5 deletions packages/opencode/src/tool/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -77,6 +78,7 @@ export const ReadTool = Tool.define("read", {
output: msg,
metadata: {
preview: msg,
truncated: false,
},
attachments: [
{
Expand All @@ -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}`
})
Expand All @@ -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)`
Expand All @@ -128,6 +145,7 @@ export const ReadTool = Tool.define("read", {
output,
metadata: {
preview,
truncated,
},
}
},
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" })
Expand Down Expand Up @@ -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 = Truncate.output(result)
const out = await Truncate.output(result, {}, initCtx?.agent)
return {
title: "",
output: out.truncated ? out.content : result,
Expand Down
12 changes: 8 additions & 4 deletions packages/opencode/src/tool/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -50,8 +50,8 @@ export namespace Tool {
): Info<Parameters, Result> {
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 {
Expand All @@ -66,7 +66,11 @@ export namespace Tool {
)
}
const result = await execute(args, ctx)
const truncated = Truncate.output(result.output)
// 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,
output: truncated.content,
Expand Down
106 changes: 106 additions & 0 deletions packages/opencode/src/tool/truncation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
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"

// 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
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 {
maxLines?: number
maxBytes?: number
direction?: "head" | "tail"
}

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(() => {})
}
})

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<Result> {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const direction = options.direction ?? "head"
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")

if (lines.length <= maxLines && totalBytes <= maxBytes) {
return { content: text, truncated: false }
}

const out: string[] = []
var i = 0
var bytes = 0
var hitBytes = false

if (direction === "head") {
for (i = 0; i < lines.length && i < maxLines; i++) {
const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
if (bytes + size > maxBytes) {
hitBytes = true
break
}
out.push(lines[i])
bytes += size
}
} 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
}
}

const removed = hitBytes ? totalBytes - bytes : lines.length - out.length
const unit = hitBytes ? "chars" : "lines"
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 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\n${hint}`
: `...${removed} ${unit} truncated...\n\n${hint}\n\n${preview}`

return { content: message, truncated: true, outputPath: filepath }
}
}
Loading