diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 6bc800ffc..1b1d7fcbe 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -97,7 +97,6 @@ const OVERFLOW_PATTERNS = [ /context window exceeds limit/i, // MiniMax /exceeded model token limit/i, // Kimi For Coding, Moonshot /context[_ ]length[_ ]exceeded/i, // Generic fallback - /request entity too large/i, // HTTP 413 /context length is only \d+ tokens/i, // vLLM /input length.*exceeds.*context length/i, // vLLM /prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error @@ -117,10 +116,13 @@ function isOpenAiErrorRetryable(e: APICallError) { function isOverflow(message: string) { if (OVERFLOW_PATTERNS.some((p) => p.test(message))) return true - // Providers/status patterns handled outside of regex list: - // - Cerebras: often returns "400 (no body)" / "413 (no body)" - // - Mistral: often returns "400 (no body)" / "413 (no body)" - return /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message) + // Cerebras and Mistral often return "400 (no body)" for context overflow. + // HTTP 413 is deliberately classified as request-body size below. + return /^400\s*(status code)?\s*\(no body\)/i.test(message) +} + +function isRequestTooLarge(message: string) { + return /request entity too large|payload too large|^413\s*(status code)?\s*\(no body\)/i.test(message) } // Billing/quota failures providers report under inconsistent status codes and @@ -312,6 +314,12 @@ export type ParsedStreamError = message: string responseBody: string } + | { + type: "request_too_large" + message: string + responseBody: string + code: "request_too_large" | "payload_too_large" + } | { type: "api_error" message: string @@ -321,9 +329,34 @@ export type ParsedStreamError = code?: string } +function parseBareStreamMessage(input: unknown): ParsedStreamError | undefined { + const message = + typeof input === "string" + ? input + : isRecord(input) && typeof input.message === "string" + ? input.message + : undefined + if (!message) return + if (isOverflow(message)) { + return { + type: "context_overflow", + message, + responseBody: message, + } + } + if (isRequestTooLarge(message)) { + return { + type: "request_too_large", + message, + responseBody: message, + code: "request_too_large", + } + } +} + export function parseStreamError(input: unknown): ParsedStreamError | undefined { const raw = json(input) - if (!isRecord(raw)) return + if (!isRecord(raw)) return parseBareStreamMessage(input) const inner = typeof raw.message === "string" ? json(raw.message) : undefined const cause = isRecord(raw.cause) ? raw.cause : undefined @@ -334,20 +367,33 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined const responseBody = JSON.stringify(body) const error = body.type === "error" && isRecord(body.error) ? body.error : isBareProviderError(body) ? body : undefined - if (!error) return + if (!error) return parseBareStreamMessage(input) // Read code from the resolved error only (never dig into an untyped body — // that is the over-match guard `if (!error) return` above protects). Fall back // to error.type so providers that put the code under `type` still classify. const code = typeof error.code === "string" ? error.code : typeof error.type === "string" ? error.type : undefined + const providerMessage = typeof error.message === "string" ? error.message : undefined + const wrapperMessage = typeof raw.message === "string" && !isStreamErrorBody(inner) ? raw.message : undefined + const evidence = `${providerMessage ?? ""}\n${typeof raw.message === "string" ? raw.message : ""}` + if (code === "context_length_exceeded" || isOverflow(evidence)) { + return { + type: "context_overflow", + message: providerMessage ?? wrapperMessage ?? "Input exceeds context window of this model", + responseBody, + } + } + if (code === "request_too_large" || code === "payload_too_large" || isRequestTooLarge(evidence)) { + return { + type: "request_too_large", + message: providerMessage ?? wrapperMessage ?? "Provider request is too large.", + responseBody, + code: code === "payload_too_large" ? code : "request_too_large", + } + } + switch (code) { - case "context_length_exceeded": - return { - type: "context_overflow", - message: "Input exceeds context window of this model", - responseBody, - } case "insufficient_quota": return { type: "api_error", @@ -409,7 +455,6 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined } } - const providerMessage = typeof error.message === "string" ? error.message : undefined const text = `${providerMessage ?? ""}\n${responseBody}` // No statusCode on the stream path, so only the unconditional strong billing // patterns can match here (weak patterns are status-gated). @@ -453,6 +498,13 @@ export type ParsedAPICallError = message: string responseBody?: string } + | { + type: "request_too_large" + message: string + statusCode?: number + responseBody?: string + code: string + } | { type: "api_error" message: string @@ -470,13 +522,25 @@ export function parseAPICallError(input: { providerID: ProviderID; error: APICal const body = json(input.error.responseBody) const code = extractProviderCode(body) const modelUnavailable = isOpenCodeModelUnavailable(input.providerID, body) - if (isOverflow(m) || input.error.statusCode === 413 || code === "context_length_exceeded") { + const requestTooLarge = + code === "request_too_large" || code === "payload_too_large" || isRequestTooLarge(m) + const contextOverflow = code === "context_length_exceeded" || isOverflow(m) + if (contextOverflow) { return { type: "context_overflow", message: m, responseBody: input.error.responseBody, } } + if (input.error.statusCode === 413 || requestTooLarge) { + return { + type: "request_too_large", + message: m, + statusCode: input.error.statusCode, + responseBody: input.error.responseBody, + code: code ?? "request_too_large", + } + } const metadata = input.error.url ? { url: input.error.url } : undefined // Billing failures arrive under inconsistent statuses (DeepSeek 402 or a diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index bd60d7f11..f771d1c25 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -246,7 +246,7 @@ export const layer: Layer.Layer< }) { // Size the retained tail the way it actually re-enters the live prompt: // prompt.ts serializes messages with no options, i.e. full media and - // un-truncated tool output. Only the head is summarized cheaply (stripMedia + // un-truncated tool output. Only the head is summarized cheaply (stripped media // + truncation in processCompaction()); the tail is kept verbatim. Using // those head options here undercounts the tail, so select()/splitTurn() // keep recent turns that overflow the very next live prompt. @@ -493,7 +493,7 @@ export const layer: Layer.Layer< const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { - stripMedia: true, + mediaProjection: "stripped", toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, toolInputMaxChars: TOOL_INPUT_MAX_CHARS, }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index c13fa7ae6..fee8c7c99 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -870,13 +870,109 @@ function providerMeta(metadata: Record | undefined) { return Object.keys(rest).length > 0 ? rest : undefined } +export type MediaProjection = "normal" | "degraded" | "stripped" + +export interface ModelMessageProjectionOptions { + mediaProjection?: MediaProjection + mediaMaxBytes?: number + mediaMaxCount?: number + toolOutputMaxChars?: number + toolInputMaxChars?: number +} + +const MEDIA_MAX_BYTES = 12 * 1024 * 1024 +const MEDIA_MAX_COUNT = 8 +const MEDIA_DEGRADED_MAX_COUNT = 2 + +function shouldSerializeMessage(message: WithParts) { + if (message.info.role !== "assistant" || !message.info.error) return true + return ( + AbortedError.isInstance(message.info.error) && + message.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") + ) +} + +function mediaCandidates(input: WithParts[]) { + const candidates: FilePart[] = [] + for (const message of input) { + if (!shouldSerializeMessage(message)) continue + for (const part of message.parts) { + if (part.type === "file" && isMedia(part.mime)) candidates.push(part) + if (part.type !== "tool" || part.state.status !== "completed" || part.state.time.compacted) continue + for (const attachment of part.state.attachments ?? []) { + if (isMedia(attachment.mime)) candidates.push(attachment) + } + } + } + return candidates +} + +function mediaRequestBytes(part: FilePart) { + if (!part.url.startsWith("data:")) return 0 + const comma = part.url.indexOf(",") + if (comma === -1) return new TextEncoder().encode(part.url).byteLength + return new TextEncoder().encode(part.url.slice(comma + 1)).byteLength +} + +function selectedMedia(input: WithParts[], options?: ModelMessageProjectionOptions) { + const projection = options?.mediaProjection ?? "normal" + const configuredMax = options?.mediaMaxCount ?? MEDIA_MAX_COUNT + const maxCount = + projection === "stripped" + ? 0 + : projection === "degraded" + ? Math.min(configuredMax, MEDIA_DEGRADED_MAX_COUNT) + : configuredMax + const maxBytes = projection === "stripped" ? 0 : (options?.mediaMaxBytes ?? MEDIA_MAX_BYTES) + const selected = new Set() + let usedBytes = 0 + for (const part of mediaCandidates(input).reverse()) { + const bytes = mediaRequestBytes(part) + if (selected.size >= Math.max(0, maxCount) || usedBytes + bytes > Math.max(0, maxBytes)) continue + selected.add(part) + usedBytes += bytes + } + return selected +} + +function mediaProjectionsAfter(current: MediaProjection) { + const projections: Exclude[] = + current === "normal" ? ["degraded", "stripped"] : current === "degraded" ? ["stripped"] : [] + return projections +} + +function modelMessagesBytes(messages: ModelMessage[]) { + return new TextEncoder().encode(JSON.stringify(messages)).byteLength +} + +export async function nextMediaMessages( + input: WithParts[], + model: Provider.Model, + current: MediaProjection, + currentMessages: ModelMessage[], + suffix: ModelMessage[] = [], +) { + const currentBytes = modelMessagesBytes(currentMessages) + const projections = mediaProjectionsAfter(current) + for (const projection of projections) { + const messages = [...(await toModelMessages(input, model, { mediaProjection: projection })), ...suffix] + if (modelMessagesBytes(messages) >= currentBytes) continue + return { projection, messages } + } +} + +function omittedMedia(part: Pick) { + return `[Attached ${part.mime}: ${part.filename ?? "file"} omitted to fit the provider request limit]` +} + export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number; toolInputMaxChars?: number }, + options?: ModelMessageProjectionOptions, ) { const result: UIMessage[] = [] const toolNames = new Set() + const includedMedia = selectedMedia(input, options) // Track media from tool results that need to be injected as user messages // for providers that don't support that media type in tool results. // @@ -933,7 +1029,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( } for (const msg of input) { - if (msg.parts.length === 0) continue + if (msg.parts.length === 0 || !shouldSerializeMessage(msg)) continue if (msg.info.role === "user") { const userMessage: UIMessage = { @@ -949,10 +1045,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) // text/plain and directory files are converted into text parts, ignore them if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { - if (options?.stripMedia && isMedia(part.mime)) { + if (isMedia(part.mime) && !includedMedia.has(part)) { userMessage.parts.push({ type: "text", - text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`, + text: omittedMedia(part), }) } else { userMessage.parts.push({ @@ -984,15 +1080,6 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` const media: Array<{ mime: string; url: string; filename?: string }> = [] - if ( - msg.info.error && - !( - AbortedError.isInstance(msg.info.error) && - msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) - ) { - continue - } const assistantMessage: UIMessage = { id: msg.info.id, role: "assistant", @@ -1018,10 +1105,17 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( if (part.type === "tool") { toolNames.add(part.tool) if (part.state.status === "completed") { - const outputText = part.state.time.compacted + const baseOutputText = part.state.time.compacted ? "[Old tool result content cleared]" : truncateToolOutput(part.state.output, options?.toolOutputMaxChars) - const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? []) + const sourceAttachments = part.state.time.compacted ? [] : (part.state.attachments ?? []) + const omittedAttachments = sourceAttachments.filter( + (attachment) => isMedia(attachment.mime) && !includedMedia.has(attachment), + ) + const outputText = [baseOutputText, ...omittedAttachments.map(omittedMedia)].filter(Boolean).join("\n") + const attachments = sourceAttachments.filter( + (attachment) => !isMedia(attachment.mime) || includedMedia.has(attachment), + ) // For providers that don't support media in tool results, extract media files // (images, PDFs) to be sent as a separate user message @@ -1154,7 +1248,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( export function toModelMessages( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number; toolInputMaxChars?: number }, + options?: ModelMessageProjectionOptions, ): Promise { return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) } @@ -1421,6 +1515,19 @@ export function fromError( { cause: e }, ).toObject() } + if (parsed.type === "request_too_large") { + return new APIError( + { + message: parsed.message, + statusCode: parsed.statusCode, + isRetryable: false, + responseBody: parsed.responseBody, + providerID: ctx.providerID, + providerFailure: { kind: "invalid_request", code: parsed.code }, + }, + { cause: e }, + ).toObject() + } return new APIError( { @@ -1452,6 +1559,18 @@ export function fromError( { cause: e }, ).toObject() } + if (parsed.type === "request_too_large") { + return new APIError( + { + message: parsed.message, + isRetryable: false, + responseBody: parsed.responseBody, + providerID: ctx.providerID, + providerFailure: { kind: "invalid_request", code: parsed.code }, + }, + { cause: e }, + ).toObject() + } return new APIError( { message: parsed.message, diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f9eedc410..8703a1429 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -49,6 +49,16 @@ export type Result = "compact" | "stop" | "continue" export type Event = LLM.Event type ProcessInput = LLM.StreamInput & { + nextMediaMessages?: ( + current: MessageV2.MediaProjection, + currentMessages: LLM.StreamInput["messages"], + ) => Promise< + | { + projection: Exclude + messages: LLM.StreamInput["messages"] + } + | undefined + > toolDrainTimeoutMs?: number } @@ -451,6 +461,11 @@ export const layer: Layer.Layer< providerID: input.model.providerID, aborted, }) + const isRequestTooLarge = (error: NonNullable) => + MessageV2.APIError.isInstance(error) && + (error.data.statusCode === 413 || + error.data.providerFailure?.code === "request_too_large" || + error.data.providerFailure?.code === "payload_too_large") const releaseToolLifecycleWaiters = Effect.fn("SessionProcessor.releaseToolLifecycleWaiters")(function* ( call: ToolLifecycleRecord, @@ -1738,6 +1753,9 @@ export const layer: Layer.Layer< let processAttemptID: RunObservability.AttemptID | undefined let automaticStreamRetriesUsed = 0 let safeRetryNoticeWritten = false + let mediaProjection: MessageV2.MediaProjection = "normal" + let projectedMediaMessages: LLM.StreamInput["messages"] | undefined + let currentMessages = streamInput.messages const retryStillAllowed = Effect.fn("SessionProcessor.retryStillAllowed")(function* (stage: string) { const lifecycleAction = currentLifecycleCloseAction(ctx.directory) @@ -1771,11 +1789,16 @@ export const layer: Layer.Layer< return { allowed: true as const } }) - const retrySignalFor = (error: unknown) => { + const retrySignalFor = ( + error: unknown, + parsed: NonNullable, + canReduceRequestMedia: boolean, + ) => { const phase = watchdogPhase(error) if (phase) { return { retryable: true, + requestTooLarge: false, message: "Connection timed out" as string | undefined, watchdog: { phase } as { phase: "connect" | "silent_stream" | "unknown" } | undefined, providerFailure: undefined as @@ -1790,7 +1813,6 @@ export const layer: Layer.Layer< // transport disconnect. The provider's own message rides along too so // the halt path renders it without a second parse that could drift // from this classification. - const parsed = parse(error) const apiError = MessageV2.APIError.isInstance(parsed) ? parsed : undefined const providerFailure = apiError?.data.providerFailure ? { @@ -1802,14 +1824,46 @@ export const layer: Layer.Layer< } : undefined const providerMessage = apiError?.data.message + const requestTooLarge = isRequestTooLarge(parsed) + if (requestTooLarge) { + return { + retryable: canReduceRequestMedia, + requestTooLarge: true, + message: providerMessage, + watchdog: undefined, + providerFailure, + providerMessage, + } + } const classification = SessionRetry.classifyRetry(parsed) if (!classification) - return { retryable: false, message: undefined, watchdog: undefined, providerFailure, providerMessage } + return { + retryable: false, + requestTooLarge: false, + message: undefined, + watchdog: undefined, + providerFailure, + providerMessage, + } if (SessionRetry.retryAction(classification) === "stop") { ctx.terminalClassification = classification - return { retryable: false, message: undefined, watchdog: undefined, providerFailure, providerMessage } + return { + retryable: false, + requestTooLarge: false, + message: undefined, + watchdog: undefined, + providerFailure, + providerMessage, + } + } + return { + retryable: true, + requestTooLarge: false, + message: classification.raw, + watchdog: undefined, + providerFailure, + providerMessage, } - return { retryable: true, message: classification.raw, watchdog: undefined, providerFailure, providerMessage } } const removeReasoningForAttempt = Effect.fn("SessionProcessor.removeReasoningForAttempt")(function* ( @@ -1896,10 +1950,14 @@ export const layer: Layer.Layer< }) let stream: Stream.Stream try { + const messages = projectedMediaMessages ?? streamInput.messages + currentMessages = messages + const { nextMediaMessages: _, toolDrainTimeoutMs: __, ...llmInput } = streamInput stream = llm.stream({ - ...ProviderTransform.streamTimeouts(streamInput.model), - ...streamInput, + ...ProviderTransform.streamTimeouts(llmInput.model), + ...llmInput, ...sessionTimeouts, + messages, tools: activeTools, trace: ctx.trace, toolAbortSignal: toolAbortController.signal, @@ -1943,7 +2001,17 @@ export const layer: Layer.Layer< if (result.ok !== false) break const attemptID = processAttemptID - const retrySignal = retrySignalFor(result.error) + const parsedError = parse(result.error) + const nextMedia = yield* Effect.promise(() => + isRequestTooLarge(parsedError) && streamInput.nextMediaMessages + ? streamInput.nextMediaMessages(mediaProjection, currentMessages) + : Promise.resolve(undefined), + ) + const retrySignal = retrySignalFor( + result.error, + parsedError, + nextMedia !== undefined, + ) yield* finalizeToolLifecycles() const decision = ctx.runTrace.recordAttemptFailureAndDeriveRecovery({ attemptID, @@ -2003,6 +2071,33 @@ export const layer: Layer.Layer< break } + if ( + attemptID && + nextMedia && + retrySignal.requestTooLarge && + retryDecision.canRetry && + retryDecision.recoveryMode === "replay" + ) { + const beforeRetry = yield* retryStillAllowed("before_media_projection_retry") + if (beforeRetry.allowed) { + automaticStreamRetriesUsed += 1 + mediaProjection = nextMedia.projection + projectedMediaMessages = nextMedia.messages + yield* removeReasoningForAttempt(attemptID) + ctx.runTrace.recordAutoRetryAttempted({ + attemptID, + at: Date.now(), + monotonicMs: performance.now(), + }) + continue + } + yield* halt(result.error, attemptID, { + recordFailure: false, + interruptionMessage: beforeRetry.interruptionMessage, + }) + break + } + if (attemptID && retryDecision.canRetry && retryDecision.recoveryMode === "replay") { const beforeRetry = yield* retryStillAllowed("before_backoff") if (beforeRetry.allowed) { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index c6a09f82f..bc0d285d1 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -251,8 +251,9 @@ export function deriveCommandTitleSeed(parts: ReadonlyArray): st return "Command: /" + name + (args.length > 0 ? " " + args : "") } -function officePathOnly(filepath: string) { - return OFFICE_EXTS.has(pathSuffix(filepath)) +function documentPathOnly(filepath: string) { + const suffix = pathSuffix(filepath) + return OFFICE_EXTS.has(suffix) || suffix === "pdf" } function attachedLocalFileText(filepath: string, filename?: string) { @@ -1537,7 +1538,7 @@ export const layer = Layer.effect( } if (part.mime === "text/plain") { - if (officePathOnly(filepath)) { + if (documentPathOnly(filepath)) { return [ { messageID: info.id, @@ -2272,6 +2273,7 @@ export const layer = Layer.effect( ] const format = lastUser.format ?? { type: "text" as const } if (format.type === "json_schema") system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT) + const messageSuffix = isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [] const result = yield* handle.process({ user: lastUser, agent, @@ -2279,7 +2281,9 @@ export const layer = Layer.effect( sessionID, parentSessionID: session.parentID, system, - messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant" as const, content: MAX_STEPS }] : [])], + messages: [...modelMsgs, ...messageSuffix], + nextMediaMessages: (current, currentMessages) => + MessageV2.nextMediaMessages(msgs, model, current, currentMessages, messageSuffix), tools, availableDeferredTools: resolvedTools.availableDeferredTools, model, diff --git a/packages/opencode/src/session/run-incident/policy.ts b/packages/opencode/src/session/run-incident/policy.ts index 2ebd533be..5f87e93c6 100644 --- a/packages/opencode/src/session/run-incident/policy.ts +++ b/packages/opencode/src/session/run-incident/policy.ts @@ -63,7 +63,6 @@ export function recoveryFor(input: { } if ( canAutoRetryBeforeFirstProviderProgress({ - cause: input.cause, facts: input.facts, terminalFacts, retryableProviderFailure, @@ -193,14 +192,12 @@ export function recoveryFor(input: { } function canAutoRetryBeforeFirstProviderProgress(input: { - cause: TerminalCause facts: IncidentFacts terminalFacts: IncidentFacts retryableProviderFailure: boolean }) { if (!input.retryableProviderFailure) return false if (input.facts.user_cancel_seen || input.facts.lifecycle_close_seen) return false - if (!isBeforeFirstProviderProgressCause(input.cause)) return false if (input.terminalFacts.provider_progress_seen) return false if (!attemptHasNoOutputOrToolActivity(input.terminalFacts)) return false return boundaryAllowsBeforeProgressRetry(input.terminalFacts.side_effect_boundary_snapshot) diff --git a/packages/opencode/src/util/media.ts b/packages/opencode/src/util/media.ts index 054e9c527..63e7b40c1 100644 --- a/packages/opencode/src/util/media.ts +++ b/packages/opencode/src/util/media.ts @@ -15,7 +15,12 @@ export function isPdfAttachment(mime: string) { } export function isMedia(mime: string) { - return mime.startsWith("image/") || isPdfAttachment(mime) + return ( + mime.startsWith("image/") || + mime.startsWith("audio/") || + mime.startsWith("video/") || + isPdfAttachment(mime) + ) } export function isImageAttachment(mime: string) { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index f58403d6d..db9d79f75 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -551,6 +551,316 @@ describe("session.message-v2.toModelMessage", () => { }) }) + test("keeps the two most recent media in the degraded request projection", async () => { + const userID = "m-user-media-budget" + const media = (index: number): MessageV2.FilePart => ({ + ...basePart(userID, `file-${index}`), + type: "file", + mime: "image/png", + filename: `image-${index}.png`, + url: `data:image/png;base64,${Buffer.from(`image-${index}`).toString("base64")}`, + }) + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "compare these images", + }, + media(1), + media(2), + media(3), + ] as MessageV2.Part[], + }, + ] + + expect( + await MessageV2.toModelMessages(input, model, { + mediaProjection: "degraded", + }), + ).toStrictEqual([ + { + role: "user", + content: [ + { type: "text", text: "compare these images" }, + { + type: "text", + text: "[Attached image/png: image-1.png omitted to fit the provider request limit]", + }, + { + type: "file", + mediaType: "image/png", + filename: "image-2.png", + data: media(2).url, + }, + { + type: "file", + mediaType: "image/png", + filename: "image-3.png", + data: media(3).url, + }, + ], + }, + ]) + }) + + test("has no lower request media projection when history contains no media", async () => { + const userID = "m-user-no-media" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "a large text-only request", + }, + ] as MessageV2.Part[], + }, + ] + + const messages = await MessageV2.toModelMessages(input, model) + expect(await MessageV2.nextMediaMessages(input, model, "normal", messages)).toBeUndefined() + }) + + test("skips degraded projection when it would retain the same media", async () => { + const userID = "m-user-one-media" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "file-1"), + type: "file", + mime: "image/png", + filename: "only-image.png", + url: "data:image/png;base64,aW1hZ2U=", + }, + ] as MessageV2.Part[], + }, + ] + + const messages = await MessageV2.toModelMessages(input, model) + expect(await MessageV2.nextMediaMessages(input, model, "normal", messages)).toMatchObject({ + projection: "stripped", + }) + }) + + test("has no lower request projection when omission markers would not shrink the payload", async () => { + const userID = "m-user-tiny-media" + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: Array.from({ length: 3 }, (_, index) => ({ + ...basePart(userID, `tiny-${index}`), + type: "file", + mime: "image/png", + filename: "x", + url: "data:image/png;base64,", + })) as MessageV2.Part[], + }, + ] + + const messages = await MessageV2.toModelMessages(input, model) + expect(await MessageV2.nextMediaMessages(input, model, "normal", messages)).toBeUndefined() + }) + + test("uses only serializable messages when selecting request media", async () => { + const userID = "m-user-visible-media" + const assistantID = "m-assistant-filtered-media" + const media = (messageID: string, id: string): MessageV2.FilePart => ({ + ...basePart(messageID, id), + type: "file", + mime: "image/png", + filename: `${id}.png`, + url: `data:image/png;base64,${Buffer.from(id).toString("base64")}`, + }) + const visible = media(userID, "visible") + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [visible] as MessageV2.Part[], + }, + { + info: assistantInfo( + assistantID, + userID, + new MessageV2.APIError({ + message: "fatal provider failure", + isRetryable: false, + }).toObject() as MessageV2.APIError, + ), + parts: [ + { + ...basePart(assistantID, "tool-filtered-media"), + type: "tool", + callID: "call-filtered-media", + tool: "read", + state: { + status: "completed", + input: {}, + output: "filtered", + title: "Read", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: Array.from({ length: 8 }, (_, index) => media(assistantID, `filtered-${index}`)), + }, + }, + ] as MessageV2.Part[], + }, + ] + + const messages = await MessageV2.toModelMessages(input, model) + expect(JSON.stringify(messages)).toContain(visible.url) + expect(await MessageV2.nextMediaMessages(input, model, "normal", messages)).toMatchObject({ + projection: "stripped", + }) + }) + + test("keeps the most recent media when the aggregate request bytes are over budget", async () => { + const userID = "m-user-media-byte-budget" + const media = (index: number): MessageV2.FilePart => ({ + ...basePart(userID, `byte-file-${index}`), + type: "file", + mime: "image/png", + filename: `byte-image-${index}.png`, + url: `data:image/png;base64,${Buffer.from(`data-${index}`).toString("base64")}`, + }) + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [media(1), media(2), media(3)] as MessageV2.Part[], + }, + ] + + const messages = await MessageV2.toModelMessages(input, model, { + mediaMaxBytes: 16, + }) + const serialized = JSON.stringify(messages) + expect(serialized).toContain("byte-image-1.png omitted to fit the provider request limit") + expect(serialized).not.toContain(media(1).url) + expect(serialized).toContain(media(2).url) + expect(serialized).toContain(media(3).url) + }) + + test("applies the aggregate media budget to tool-result attachments", async () => { + const userID = "m-user-tool-media-budget" + const assistantID = "m-assistant-tool-media-budget" + const attachment = (index: number): MessageV2.FilePart => ({ + ...basePart(assistantID, `tool-file-${index}`), + type: "file", + mime: "application/pdf", + filename: `report-${index}.pdf`, + url: `data:application/pdf;base64,${Buffer.from(`pdf-${index}`).toString("base64")}`, + }) + const tool = (index: number): MessageV2.ToolPart => ({ + ...basePart(assistantID, `tool-${index}`), + type: "tool", + callID: `call-${index}`, + tool: "read", + state: { + status: "completed", + input: { filePath: `report-${index}.pdf` }, + output: `read report ${index}`, + title: "Read", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [attachment(index)], + }, + }) + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "compare reports", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [tool(1), tool(2)] as MessageV2.Part[], + }, + ] + + const serialized = JSON.stringify( + await MessageV2.toModelMessages(input, model, { + mediaMaxCount: 1, + }), + ) + expect(serialized).toContain("report-1.pdf omitted to fit the provider request limit") + expect(serialized).not.toContain(attachment(1).url.slice(attachment(1).url.indexOf(",") + 1)) + expect(serialized).toContain(attachment(2).url.slice(attachment(2).url.indexOf(",") + 1)) + }) + + test("strips audio and video tool-result attachments from provider messages", async () => { + const userID = "m-user-tool-av-budget" + const assistantID = "m-assistant-tool-av-budget" + const audioData = "audio-payload".repeat(100) + const videoData = "video-payload".repeat(100) + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "inspect media", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "tool-av"), + type: "tool", + callID: "call-av", + tool: "mcp_resource", + state: { + status: "completed", + input: {}, + output: "media resources", + title: "MCP resource", + metadata: {}, + time: { start: 0, end: 1 }, + attachments: [ + { + ...basePart(assistantID, "audio-file"), + type: "file", + mime: "audio/mpeg", + filename: "recording.mp3", + url: `data:audio/mpeg;base64,${audioData}`, + }, + { + ...basePart(assistantID, "video-file"), + type: "file", + mime: "video/mp4", + filename: "recording.mp4", + url: `data:video/mp4;base64,${videoData}`, + }, + ], + }, + }, + ] as MessageV2.Part[], + }, + ] + + const serialized = JSON.stringify( + await MessageV2.toModelMessages(input, model, { + mediaProjection: "stripped", + }), + ) + expect(serialized).toContain("audio/mpeg: recording.mp3 omitted to fit the provider request limit") + expect(serialized).toContain("video/mp4: recording.mp4 omitted to fit the provider request limit") + expect(serialized).not.toContain(audioData) + expect(serialized).not.toContain(videoData) + }) + test("moves bedrock pdf tool-result media into a separate user message", async () => { const bedrockModel: Provider.Model = { ...model, @@ -1886,7 +2196,6 @@ describe("session.message-v2.fromError", () => { "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)", "Please reduce the length of the messages or completion", "400 status code (no body)", - "413 status code (no body)", ] cases.forEach((message) => { @@ -1903,6 +2212,173 @@ describe("session.message-v2.fromError", () => { }) }) + test("classifies HTTP 413 as request size overflow instead of context overflow", () => { + const error = new APICallError({ + message: "413 status code (no body)", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 413, + responseHeaders: { "content-type": "application/json" }, + isRetryable: false, + }) + + const result = MessageV2.fromError(error, { providerID }) + expect(result).toMatchObject({ + name: "APIError", + data: { + statusCode: 413, + providerFailure: { kind: "invalid_request", code: "request_too_large" }, + }, + }) + expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(false) + }) + + test("classifies bare request-size errors without structured provider metadata", () => { + const cases = ["413 status code (no body)", "Payload Too Large", "Request Entity Too Large"] + + for (const message of cases) { + expect(MessageV2.fromError(new Error(message), { providerID })).toMatchObject({ + name: "APIError", + data: { + message, + isRetryable: false, + providerFailure: { kind: "invalid_request", code: "request_too_large" }, + }, + }) + } + }) + + test("classifies message-only typed stream request-size errors", () => { + const cases = ["413 status code (no body)", "Payload Too Large", "Request Entity Too Large"] + + for (const message of cases) { + const payload = JSON.stringify({ type: "error", error: { message } }) + expect(MessageV2.fromError(new Error(payload), { providerID })).toMatchObject({ + name: "APIError", + data: { + message, + isRetryable: false, + responseBody: payload, + providerFailure: { kind: "invalid_request", code: "request_too_large" }, + }, + }) + } + }) + + test("keeps explicit context-window evidence ahead of request-size text in typed stream errors", () => { + for (const code of [undefined, "request_too_large", "payload_too_large"]) { + const payload = JSON.stringify({ + type: "error", + error: { + code, + message: "Input exceeds the context window of this model. Payload Too Large.", + }, + }) + + expect(MessageV2.ContextOverflowError.isInstance(MessageV2.fromError(new Error(payload), { providerID }))).toBe( + true, + ) + } + }) + + test("uses typed stream top-level and wrapper messages as classification evidence", () => { + const contextMessage = "Input exceeds the context window of this model" + const requestBody = { + type: "error", + error: { + code: "request_too_large", + }, + } + const topLevel = { + ...requestBody, + message: contextMessage, + } + const wrapped = new Error(contextMessage, { + cause: { + body: requestBody, + }, + }) + + for (const error of [topLevel, wrapped]) { + expect(MessageV2.ContextOverflowError.isInstance(MessageV2.fromError(error, { providerID }))).toBe(true) + } + + expect( + MessageV2.fromError( + { + type: "error", + message: "Payload Too Large", + error: {}, + }, + { providerID }, + ), + ).toMatchObject({ + name: "APIError", + data: { + message: "Payload Too Large", + providerFailure: { kind: "invalid_request", code: "request_too_large" }, + }, + }) + }) + + test("keeps explicit context_length_exceeded precedence over HTTP 413", () => { + const error = new APICallError({ + message: "Request failed", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 413, + responseHeaders: { "content-type": "application/json" }, + responseBody: JSON.stringify({ + error: { + message: "Input exceeds the context window", + code: "context_length_exceeded", + }, + }), + isRetryable: false, + }) + + const result = MessageV2.fromError(error, { providerID }) + expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true) + }) + + test("keeps an explicit context-window message precedence over bare HTTP 413", () => { + const error = new APICallError({ + message: "Request failed", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 413, + responseHeaders: { "content-type": "application/json" }, + responseBody: JSON.stringify({ + error: { + message: "Input exceeds the context window of this model", + }, + }), + isRetryable: false, + }) + + const result = MessageV2.fromError(error, { providerID }) + expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true) + }) + + test("keeps an explicit context-window message precedence over the HTTP 413 reason phrase", () => { + const error = new APICallError({ + message: "Payload Too Large", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 413, + responseHeaders: { "content-type": "application/json" }, + responseBody: JSON.stringify({ + error: { + message: "Input exceeds the context window of this model", + }, + }), + isRetryable: false, + }) + + const result = MessageV2.fromError(error, { providerID }) + expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true) + }) + test("detects context overflow from context_length_exceeded code in response body", () => { const error = new APICallError({ message: "Request failed", diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index d2713e7ae..142a4b787 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1517,6 +1517,262 @@ it.live("surfaces a terminal provider API error's real message instead of a conn ), ) +it.live("retries HTTP 413 media projections with an unclassified local tool boundary", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.error(413, { error: "request entity too large" }) + yield* llm.error(413, { error: "request entity too large" }) + yield* llm.text("recovered") + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "inspect attachments") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + safeRecoveryDelay: FAST_SAFE_RECOVERY_DELAY, + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "normal media" }], + nextMediaMessages: async (current) => { + const projection = current === "normal" ? "degraded" : current === "degraded" ? "stripped" : undefined + if (!projection) return + return { + projection, + messages: [{ role: "user" as const, content: `${projection} media` }], + } + }, + tools: { + edit: tool({ + description: "ordinary local tool with incomplete effect classification", + inputSchema: z.object({}), + }), + }, + }) + + const inputs = yield* llm.inputs + expect(value).toBe("continue") + expect( + inputs.map( + (input) => (input.messages as Array<{ content?: unknown }> | undefined)?.at(-1)?.content, + ), + ).toEqual(["normal media", "degraded media", "stripped media"]) + expect(handle.message.error).toBeUndefined() + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) + +it.live("retries a message-only typed stream 413 with a smaller media projection", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.push( + raw({ + chunks: [{ type: "error", error: { message: "Payload Too Large" } }], + }), + ) + yield* llm.text("recovered") + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "inspect attachments") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + safeRecoveryDelay: FAST_SAFE_RECOVERY_DELAY, + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + const originalMessages = [{ role: "user" as const, content: "normal media payload" }] + const projectedMessages = [{ role: "user" as const, content: "stripped" }] + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: originalMessages, + nextMediaMessages: async (current) => + current === "normal" + ? { + projection: "stripped", + messages: projectedMessages, + } + : undefined, + tools: {}, + }) + + const inputs = yield* llm.inputs + const inputMessages = inputs.map((input) => input.messages) + const bytes = (messages: unknown) => new TextEncoder().encode(JSON.stringify(messages)).byteLength + expect(value).toBe("continue") + expect( + inputMessages.map( + (messages) => (messages as Array<{ content?: unknown }> | undefined)?.at(-1)?.content, + ), + ).toEqual(["normal media payload", "stripped"]) + expect(bytes(inputMessages[1])).toBeLessThan(bytes(inputMessages[0])) + expect(handle.message.error).toBeUndefined() + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) + +it.live("compacts typed stream context overflow before considering request-size projection", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.push( + raw({ + chunks: [ + { + type: "error", + error: { + code: "request_too_large", + message: "Input exceeds the context window of this model. Payload Too Large.", + }, + }, + ], + }), + ) + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "inspect attachments") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + safeRecoveryDelay: FAST_SAFE_RECOVERY_DELAY, + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + let projectionCalls = 0 + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "normal media payload" }], + nextMediaMessages: async () => { + projectionCalls += 1 + return { + projection: "stripped", + messages: [{ role: "user", content: "stripped" }], + } + }, + tools: {}, + }) + + expect(value).toBe("compact") + expect(yield* llm.calls).toBe(1) + expect(projectionCalls).toBe(0) + expect(handle.message.error).toBeUndefined() + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) + +it.live("stops after the stripped request media projection is also rejected with HTTP 413", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.error(413, { error: "request entity too large" }) + yield* llm.error(413, { error: "request entity too large" }) + yield* llm.error(413, { error: "request entity too large" }) + yield* llm.text("must not run") + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "inspect attachments") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + safeRecoveryDelay: FAST_SAFE_RECOVERY_DELAY, + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "normal media" }], + nextMediaMessages: async (current) => { + const projection = current === "normal" ? "degraded" : current === "degraded" ? "stripped" : undefined + if (!projection) return + return { + projection, + messages: [{ role: "user" as const, content: `${projection} media` }], + } + }, + tools: {}, + }) + + expect(value).toBe("stop") + expect(yield* llm.calls).toBe(3) + expect(handle.message.error).toMatchObject({ + name: "APIError", + data: { + statusCode: 413, + providerFailure: { kind: "invalid_request", code: "request_too_large" }, + }, + }) + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) + it.live("session.processor effect tests retry recognized structured json errors", () => provideTmpdirServer( ({ dir, llm }) => diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index f4042f9c4..07b9b83be 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1530,7 +1530,7 @@ it.live("tells the model when image content cannot be provided to it", () => ), ) -it.live("text file part promotes PDF attachment when model has image input", () => +it.live("text file part keeps PDF path-only when model has image input", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { @@ -1551,7 +1551,13 @@ it.live("text file part promotes PDF attachment when model has image input", () ], }) - expect(msg.parts.some((part) => part.type === "file" && part.mime === "application/pdf")).toBe(true) + expect(msg.parts.some((part) => part.type === "file" && part.mime === "application/pdf")).toBe(false) + expect(msg.parts.some((part) => part.type === "file" && part.url === pdfUrl)).toBe(true) + expect( + msg.parts.some( + (part) => part.type === "text" && part.synthetic && part.text === `Attached local file by path: ${pdf}`, + ), + ).toBe(true) }), { git: true, config: imageCfg }, ),