diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 805da33cc7a..fc7e8084060 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -27,6 +27,7 @@ export namespace Flag { truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA") export const OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH = number("OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH") export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS") + export const OPENCODE_EXPERIMENTAL_MCP_MAX_OUTPUT_LENGTH = number("OPENCODE_EXPERIMENTAL_MCP_MAX_OUTPUT_LENGTH") export const OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX = number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX") export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT") export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY") diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 4285223bc5c..edbd7b1cafe 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -2,9 +2,12 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Decimal } from "decimal.js" import z from "zod" +import path from "path" +import fs from "fs/promises" import { type LanguageModelUsage, type ProviderMetadata } from "ai" import { Config } from "../config/config" import { Flag } from "../flag/flag" +import { Global } from "../global" import { Identifier } from "../id/id" import { Installation } from "../installation" @@ -307,6 +310,8 @@ export namespace Session { } await Storage.remove(msg) } + const toolResultsDir = path.join(Global.Path.data, "storage", "tool_results", sessionID) + await fs.rm(toolResultsDir, { recursive: true, force: true }).catch(() => {}) 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 fabe3fa5128..461eab6cdd7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -24,6 +24,7 @@ import { clone, mergeDeep, pipe } from "remeda" import { ToolRegistry } from "../tool/registry" import { Wildcard } from "../util/wildcard" import { MCP } from "../mcp" +import { ToolResults } from "./tool-results" import { LSP } from "../lsp" import { ReadTool } from "../tool/read" import { ListTool } from "../tool/ls" @@ -50,6 +51,7 @@ globalThis.AI_SDK_LOG_WARNINGS = false export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 + const MAX_MCP_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_MCP_MAX_OUTPUT_LENGTH || 30_000 const state = Instance.state( () => { @@ -695,12 +697,25 @@ export namespace SessionPrompt { // Add support for other types if needed } + let output = textParts.join("\n\n") + let content = result.content + + if (output.length > MAX_MCP_OUTPUT_LENGTH) { + const filePath = await ToolResults.save(input.sessionID, key, output) + const isJson = output.trim().startsWith("[") || output.trim().startsWith("{") + output = `Output (${output.length.toLocaleString()} characters) exceeds maximum allowed (${MAX_MCP_OUTPUT_LENGTH.toLocaleString()}). +Output has been saved to ${filePath} +${isJson ? "Format: JSON - use jq via bash tool for structured queries.\n" : ""}Use the Read tool with offset/limit parameters to read specific portions, +or use Grep to search for specific content within the file.` + content = [{ type: "text" as const, text: output }] + } + return { title: "", metadata: result.metadata ?? {}, - output: textParts.join("\n\n"), + output, attachments, - content: result.content, // directly return content to preserve ordering when outputting to model + content, } } item.toModelOutput = (result) => { diff --git a/packages/opencode/src/session/tool-results.ts b/packages/opencode/src/session/tool-results.ts new file mode 100644 index 00000000000..7a09a4721fb --- /dev/null +++ b/packages/opencode/src/session/tool-results.ts @@ -0,0 +1,16 @@ +import path from "path" +import fs from "fs/promises" +import { ulid } from "ulid" +import { Global } from "../global" + +export namespace ToolResults { + export async function save(sessionID: string, toolName: string, content: string): Promise { + const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, "_") + const filename = `${sanitizedToolName}-${ulid()}.txt` + const dir = path.join(Global.Path.data, "storage", "tool_results", sessionID) + await fs.mkdir(dir, { recursive: true }) + const filePath = path.join(dir, filename) + await fs.writeFile(filePath, content, "utf-8") + return filePath + } +} diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 115d8f8b29d..41d6c3fdf98 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -15,8 +15,10 @@ import { fileURLToPath } from "url" import { Flag } from "@/flag/flag.ts" import path from "path" import { Shell } from "@/shell/shell" +import { ToolResults } from "@/session/tool-results" const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000 +const MAX_OUTPUT_HARD_LIMIT = 10_000_000 // 10MB hard limit to prevent memory issues const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 export const log = Log.create({ service: "bash-tool" }) @@ -216,8 +218,10 @@ export const BashTool = Tool.define("bash", async () => { }) const append = (chunk: Buffer) => { - if (output.length <= MAX_OUTPUT_LENGTH) { + if (output.length < MAX_OUTPUT_HARD_LIMIT) { output += chunk.toString() + } + if (output.length <= MAX_OUTPUT_LENGTH) { ctx.metadata({ metadata: { output, @@ -274,11 +278,6 @@ 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`) - } - if (timedOut) { resultMetadata.push(`bash tool terminated commmand after exceeding timeout ${timeout} ms`) } @@ -292,6 +291,21 @@ export const BashTool = Tool.define("bash", async () => { output += "\n\n" + resultMetadata.join("\n") } + if (output.length > MAX_OUTPUT_LENGTH) { + const wasHardLimited = output.length >= MAX_OUTPUT_HARD_LIMIT + const filePath = await ToolResults.save(ctx.sessionID, "bash", output) + const statusInfo: string[] = [] + if (proc.exitCode != null && proc.exitCode !== 0) statusInfo.push(`Exit code: ${proc.exitCode}`) + if (timedOut) statusInfo.push(`Command timed out after ${timeout}ms`) + if (aborted) statusInfo.push("Command was aborted by user") + if (wasHardLimited) statusInfo.push(`Output was truncated at ${MAX_OUTPUT_HARD_LIMIT.toLocaleString()} character limit`) + const statusLine = statusInfo.length > 0 ? `\n${statusInfo.join(". ")}.` : "" + output = `Output (${output.length.toLocaleString()} characters) exceeds maximum allowed (${MAX_OUTPUT_LENGTH.toLocaleString()}). +Output has been saved to ${filePath}${statusLine} +Use the Read tool with offset/limit parameters to read specific portions, +or use Grep to search for specific content within the file.` + } + return { title: params.description, metadata: { diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index a81deb62bf2..ca71c7df2fb 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -24,9 +24,9 @@ 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 timeout 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. + - If the output exceeds 30000 characters, it will be saved to a file and you will receive the file path. Use the Read tool with offset/limit parameters to read specific portions, or use Grep to search for specific content within the file. - 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. - + - 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) - Content search: Use Grep (NOT grep or rg) diff --git a/packages/opencode/src/tool/codesearch.ts b/packages/opencode/src/tool/codesearch.ts index 0227c06f5d5..cd6dea49916 100644 --- a/packages/opencode/src/tool/codesearch.ts +++ b/packages/opencode/src/tool/codesearch.ts @@ -3,6 +3,9 @@ import { Tool } from "./tool" import DESCRIPTION from "./codesearch.txt" import { Config } from "../config/config" import { Permission } from "../permission" +import { ToolResults } from "../session/tool-results" + +const MAX_OUTPUT_LENGTH = 30_000 const API_CONFIG = { BASE_URL: "https://mcp.exa.ai", @@ -110,8 +113,16 @@ export const CodeSearchTool = Tool.define("codesearch", { if (line.startsWith("data: ")) { const data: McpCodeResponse = JSON.parse(line.substring(6)) if (data.result && data.result.content && data.result.content.length > 0) { + let output = data.result.content[0].text + if (output.length > MAX_OUTPUT_LENGTH) { + const filePath = await ToolResults.save(ctx.sessionID, "codesearch", output) + output = `Output (${output.length.toLocaleString()} characters) exceeds maximum allowed (${MAX_OUTPUT_LENGTH.toLocaleString()}). +Output has been saved to ${filePath} +Use the Read tool with offset/limit parameters to read specific portions, +or use Grep to search for specific content within the file.` + } return { - output: data.result.content[0].text, + output, title: `Code search: ${params.query}`, metadata: {}, } diff --git a/packages/opencode/src/tool/webfetch.ts b/packages/opencode/src/tool/webfetch.ts index 0333bb018a9..81df5a6e32e 100644 --- a/packages/opencode/src/tool/webfetch.ts +++ b/packages/opencode/src/tool/webfetch.ts @@ -4,8 +4,10 @@ import TurndownService from "turndown" import DESCRIPTION from "./webfetch.txt" import { Config } from "../config/config" import { Permission } from "../permission" +import { ToolResults } from "../session/tool-results" const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB +const MAX_OUTPUT_LENGTH = 30_000 const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds const MAX_TIMEOUT = 120 * 1000 // 2 minutes @@ -94,50 +96,42 @@ export const WebFetchTool = Tool.define("webfetch", { const title = `${params.url} (${contentType})` // Handle content based on requested format and actual content type + let output: string switch (params.format) { case "markdown": if (contentType.includes("text/html")) { - const markdown = convertHTMLToMarkdown(content) - return { - output: markdown, - title, - metadata: {}, - } - } - return { - output: content, - title, - metadata: {}, + output = convertHTMLToMarkdown(content) + } else { + output = content } + break case "text": if (contentType.includes("text/html")) { - const text = await extractTextFromHTML(content) - return { - output: text, - title, - metadata: {}, - } - } - return { - output: content, - title, - metadata: {}, + output = await extractTextFromHTML(content) + } else { + output = content } + break case "html": - return { - output: content, - title, - metadata: {}, - } - default: - return { - output: content, - title, - metadata: {}, - } + output = content + break + } + + if (output.length > MAX_OUTPUT_LENGTH) { + const filePath = await ToolResults.save(ctx.sessionID, "webfetch", output) + output = `Output (${output.length.toLocaleString()} characters) exceeds maximum allowed (${MAX_OUTPUT_LENGTH.toLocaleString()}). +Output has been saved to ${filePath} +Use the Read tool with offset/limit parameters to read specific portions, +or use Grep to search for specific content within the file.` + } + + return { + output, + title, + metadata: {}, } }, }) diff --git a/packages/opencode/src/tool/websearch.ts b/packages/opencode/src/tool/websearch.ts index 4064d12f38f..2557f6eb3b8 100644 --- a/packages/opencode/src/tool/websearch.ts +++ b/packages/opencode/src/tool/websearch.ts @@ -3,6 +3,9 @@ import { Tool } from "./tool" import DESCRIPTION from "./websearch.txt" import { Config } from "../config/config" import { Permission } from "../permission" +import { ToolResults } from "../session/tool-results" + +const MAX_OUTPUT_LENGTH = 30_000 const API_CONFIG = { BASE_URL: "https://mcp.exa.ai", @@ -123,8 +126,16 @@ export const WebSearchTool = Tool.define("websearch", { if (line.startsWith("data: ")) { const data: McpSearchResponse = JSON.parse(line.substring(6)) if (data.result && data.result.content && data.result.content.length > 0) { + let output = data.result.content[0].text + if (output.length > MAX_OUTPUT_LENGTH) { + const filePath = await ToolResults.save(ctx.sessionID, "websearch", output) + output = `Output (${output.length.toLocaleString()} characters) exceeds maximum allowed (${MAX_OUTPUT_LENGTH.toLocaleString()}). +Output has been saved to ${filePath} +Use the Read tool with offset/limit parameters to read specific portions, +or use Grep to search for specific content within the file.` + } return { - output: data.result.content[0].text, + output, title: `Web search: ${params.query}`, metadata: {}, }