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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1424,6 +1424,11 @@ function InlineTool(props: {
const ctx = use()
const sync = useSync()

const truncated = createMemo(() => {
if (props.part.state.status !== "completed") return false
return props.part.state.metadata?.truncated === true
})

const permission = createMemo(() => {
const callID = sync.data.permission[ctx.sessionID]?.at(0)?.tool?.callID
if (!callID) return false
Expand Down Expand Up @@ -1470,6 +1475,9 @@ function InlineTool(props: {
<text paddingLeft={3} fg={fg()} attributes={denied() ? TextAttributes.STRIKETHROUGH : undefined}>
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
<span style={{ fg: props.iconColor }}>{props.icon}</span> {props.children}
<Show when={truncated()}>
<span style={{ fg: theme.warning }}> [truncated]</span>
</Show>
</Show>
</text>
<Show when={error() && !denied()}>
Expand All @@ -1484,6 +1492,10 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
const renderer = useRenderer()
const [hover, setHover] = createSignal(false)
const error = createMemo(() => (props.part?.state.status === "error" ? props.part.state.error : undefined))
const truncated = createMemo(() => {
if (props.part?.state.status !== "completed") return false
return props.part.state.metadata?.truncated === true
})
return (
<box
border={["left"]}
Expand All @@ -1504,6 +1516,9 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () =
>
<text paddingLeft={3} fg={theme.textMuted}>
{props.title}
<Show when={truncated()}>
<span style={{ fg: theme.warning }}> [truncated]</span>
</Show>
</text>
{props.children}
<Show when={error()}>
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { SessionPrompt } from "./prompt"
import { fn } from "@/util/fn"
import { Command } from "../command"
import { Snapshot } from "@/snapshot"
import { Truncate } from "./truncation"

import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next"
Expand Down Expand Up @@ -318,6 +319,7 @@ export namespace Session {
}
await Storage.remove(msg)
}
await Truncate.cleanupSessionFiles(sessionID)
await Storage.remove(["session", project.id, sessionID])
Bus.publish(Event.Deleted, {
info: session,
Expand Down
16 changes: 14 additions & 2 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { MessageV2 } from "./message-v2"
import { Log } from "../util/log"
import { SessionRevert } from "./revert"
import { Session } from "."
import { Truncate } from "./truncation"
import { Agent } from "../agent/agent"
import { Provider } from "../provider/provider"
import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions } from "ai"
Expand Down Expand Up @@ -781,10 +782,21 @@ export namespace SessionPrompt {
// Add support for other types if needed
}

const rawOutput = textParts.join("\n\n")
const truncated = await Truncate.outputWithPersistence(rawOutput, {
sessionID: ctx.sessionID,
toolName: key,
callID: opts.toolCallId,
})

return {
title: "",
metadata: result.metadata ?? {},
output: textParts.join("\n\n"),
metadata: {
...result.metadata,
truncated: truncated.truncated,
...(truncated.filePath && { truncatedFilePath: truncated.filePath }),
},
output: truncated.content,
attachments,
content: result.content, // directly return content to preserve ordering when outputting to model
}
Expand Down
159 changes: 159 additions & 0 deletions packages/opencode/src/session/truncation.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import path from "path"
import fs from "fs/promises"
import { ulid } from "ulid"
import { Global } from "../global"

export namespace Truncate {
export const MAX_LINES = 2000
export const MAX_BYTES = 50 * 1024
export const MAX_PERSIST_BYTES = 10 * 1024 * 1024 // 10MB hard limit for persisted files
export const PREVIEW_HEAD_LINES = 100
export const PREVIEW_TAIL_LINES = 50

export interface Result {
content: string
truncated: boolean
filePath?: string
}

export interface Options {
Expand All @@ -13,6 +22,108 @@ export namespace Truncate {
direction?: "head" | "tail"
}

export interface PersistOptions extends Options {
sessionID: string
toolName: string
callID?: string
}

function sanitizeToolName(name: string): string {
return name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 64)
}

function isLikelyJson(text: string): boolean {
const trimmed = text.trimStart()
return trimmed.startsWith("{") || trimmed.startsWith("[")
}

const MAX_PREVIEW_BYTES = 20 * 1024 // 20KB default max for preview content

interface PreviewOptions {
maxBytes?: number
maxLines?: number
}

function getPreviewHeadTail(
lines: string[],
totalBytes: number,
totalLines: number,
options: PreviewOptions = {},
): string {
const maxPreviewBytes = Math.min(options.maxBytes ?? MAX_PREVIEW_BYTES, MAX_PREVIEW_BYTES)
const maxPreviewLines = options.maxLines ?? PREVIEW_HEAD_LINES + PREVIEW_TAIL_LINES

// Calculate how many lines we can show, respecting maxLines
const headCount = Math.min(PREVIEW_HEAD_LINES, Math.floor(maxPreviewLines * 0.67))
const tailCount = Math.min(PREVIEW_TAIL_LINES, maxPreviewLines - headCount)

const headLines = lines.slice(0, headCount)
const tailLines = totalLines > headCount + tailCount ? lines.slice(-tailCount) : []
const omittedLines = totalLines - headCount - tailLines.length

let preview = headLines.join("\n")
if (omittedLines > 0 && tailLines.length > 0) {
preview += `\n\n... ${omittedLines.toLocaleString()} lines omitted (${totalBytes.toLocaleString()} bytes total) ...\n\n`
preview += tailLines.join("\n")
} else if (omittedLines > 0) {
preview += `\n\n... ${omittedLines.toLocaleString()} lines omitted (${totalBytes.toLocaleString()} bytes total) ...`
}

// Ensure preview itself doesn't exceed byte limit (handles few-lines-but-large-bytes case)
const previewBytes = Buffer.byteLength(preview, "utf-8")
if (previewBytes > maxPreviewBytes) {
const headBudget = Math.floor(maxPreviewBytes * 0.6) // 60% for head
const tailBudget = Math.floor(maxPreviewBytes * 0.3) // 30% for tail
const headBuf = Buffer.from(preview, "utf-8").subarray(0, headBudget)
const tailBuf = Buffer.from(preview, "utf-8").subarray(-tailBudget)
const truncatedHead = headBuf.toString("utf-8")
const truncatedTail = tailBuf.toString("utf-8")
preview = `${truncatedHead}\n\n... preview truncated (${totalBytes.toLocaleString()} bytes total) ...\n\n${truncatedTail}`
}

return preview
}

function isValidSessionID(sessionID: string): boolean {
return /^ses_[a-zA-Z0-9_-]+$/.test(sessionID) && !sessionID.includes("..")
}

async function saveToFile(
content: string,
sessionID: string,
toolName: string,
callID?: string,
): Promise<string> {
if (!isValidSessionID(sessionID)) {
throw new Error(`Invalid sessionID format: ${sessionID}`)
}

const sanitizedName = sanitizeToolName(toolName)
const extension = isLikelyJson(content) ? ".json" : ".txt"
const callPart = callID ? `-${callID.slice(-8)}` : ""
const filename = `${sanitizedName}${callPart}-${ulid()}${extension}`
const baseDir = path.join(Global.Path.data, "storage", "tool_results")
const dir = path.join(baseDir, sessionID)
const filePath = path.join(dir, filename)

const resolvedPath = path.resolve(filePath)
const resolvedBase = path.resolve(baseDir)
if (!resolvedPath.startsWith(resolvedBase + path.sep)) {
throw new Error(`Path traversal detected: ${filePath}`)
}

await fs.mkdir(dir, { recursive: true })

const contentBytes = Buffer.byteLength(content, "utf-8")
let contentToSave = content
if (contentBytes > MAX_PERSIST_BYTES) {
const buf = Buffer.from(content, "utf-8").subarray(0, MAX_PERSIST_BYTES)
contentToSave = buf.toString("utf-8")
}
await fs.writeFile(filePath, contentToSave, "utf-8")
return filePath
}

export function output(text: string, options: Options = {}): Result {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
Expand Down Expand Up @@ -57,4 +168,52 @@ export namespace Truncate {
const unit = hitBytes ? "chars" : "lines"
return { content: `...${removed} ${unit} truncated...\n\n${out.join("\n")}`, truncated: true }
}

export async function outputWithPersistence(
text: string,
options: PersistOptions,
): Promise<Result> {
const maxLines = options.maxLines ?? MAX_LINES
const maxBytes = options.maxBytes ?? MAX_BYTES
const lines = text.split("\n")
const totalBytes = Buffer.byteLength(text, "utf-8")

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

const filePath = await saveToFile(text, options.sessionID, options.toolName, options.callID)
const preview = getPreviewHeadTail(lines, totalBytes, lines.length, {
maxBytes: maxBytes,
maxLines: maxLines,
})
const isJson = isLikelyJson(text)
const wasCapped = totalBytes > MAX_PERSIST_BYTES

let instructions = `\n\n<truncation_notice>
WARNING: Output was truncated. The preview above is INCOMPLETE and may be missing critical data.
Full output (${totalBytes.toLocaleString()} bytes, ${lines.length.toLocaleString()} lines) saved to: ${filePath}${wasCapped ? `\nNote: Output was capped at ${MAX_PERSIST_BYTES.toLocaleString()} bytes due to size limits.` : ""}
You MUST read the file to get complete/accurate results. Use Read tool with offset/limit, Grep to search, or bash with jq for JSON.
</truncation_notice>`

return {
content: preview + instructions,
truncated: true,
filePath,
}
}

export async function cleanupSessionFiles(sessionID: string): Promise<void> {
if (!isValidSessionID(sessionID)) {
return
}
const baseDir = path.join(Global.Path.data, "storage", "tool_results")
const dir = path.join(baseDir, sessionID)
const resolvedDir = path.resolve(dir)
const resolvedBase = path.resolve(baseDir)
if (!resolvedDir.startsWith(resolvedBase + path.sep)) {
return
}
await fs.rm(dir, { recursive: true, force: true }).catch(() => {})
}
}
13 changes: 10 additions & 3 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,18 @@ 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.outputWithPersistence(result, {
sessionID: ctx.sessionID,
toolName: id,
callID: ctx.callID,
})
return {
title: "",
output: out.truncated ? out.content : result,
metadata: { truncated: out.truncated },
output: out.content,
metadata: {
truncated: out.truncated,
...(out.filePath && { truncatedFilePath: out.filePath }),
},
}
},
}),
Expand Down
7 changes: 6 additions & 1 deletion packages/opencode/src/tool/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,18 @@ export namespace Tool {
)
}
const result = await execute(args, ctx)
const truncated = Truncate.output(result.output)
const truncated = await Truncate.outputWithPersistence(result.output, {
sessionID: ctx.sessionID,
toolName: id,
callID: ctx.callID,
})
return {
...result,
output: truncated.content,
metadata: {
...result.metadata,
truncated: truncated.truncated,
...(truncated.filePath && { truncatedFilePath: truncated.filePath }),
},
}
}
Expand Down