diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index aa331ca0f0d..c8eb062cdfa 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -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 @@ -1470,6 +1475,9 @@ function InlineTool(props: { ~ {props.pending}} when={props.complete}> {props.icon} {props.children} + + [truncated] + @@ -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 ( {props.title} + + [truncated] + {props.children} diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 0776590d6a9..c101742a722 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -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" @@ -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, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index f891612272c..15b13bcf34b 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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" @@ -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 } diff --git a/packages/opencode/src/session/truncation.ts b/packages/opencode/src/session/truncation.ts index 15177a55a65..0c01fe3e228 100644 --- a/packages/opencode/src/session/truncation.ts +++ b/packages/opencode/src/session/truncation.ts @@ -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 { @@ -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 { + 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 @@ -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 { + 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 +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. +` + + return { + content: preview + instructions, + truncated: true, + filePath, + } + } + + export async function cleanupSessionFiles(sessionID: string): Promise { + 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(() => {}) + } } diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index bca6626db70..3207e6d1ef3 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -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 }), + }, } }, }), diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 060da0ae763..aef62ba7eef 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -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 }), }, } }