diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 1ad3387685d..a0f1c55c45c 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -107,6 +107,28 @@ export namespace SessionCompaction { overflow?: boolean }) { const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User + + let messages = input.messages + let replay: MessageV2.WithParts | undefined + if (input.overflow) { + const idx = input.messages.findIndex((m) => m.info.id === input.parentID) + for (let i = idx - 1; i >= 0; i--) { + const msg = input.messages[i] + if (msg.info.role === "user" && !msg.parts.some((p) => p.type === "compaction")) { + replay = msg + messages = input.messages.slice(0, i) + break + } + } + const hasContent = replay && messages.some( + (m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"), + ) + if (!hasContent) { + replay = undefined + messages = input.messages + } + } + const agent = await Agent.get("compaction") const model = agent.model ? await Provider.getModel(agent.model.providerID, agent.model.modelID) @@ -186,7 +208,7 @@ When constructing the summary, try to stick to this template: tools: {}, system: [], messages: [ - ...MessageV2.toModelMessages(input.messages, model, { stripMedia: true }), + ...MessageV2.toModelMessages(messages, model, { stripMedia: true }), { role: "user", content: [ @@ -202,7 +224,9 @@ When constructing the summary, try to stick to this template: if (result === "compact") { processor.message.error = new MessageV2.ContextOverflowError({ - message: "Session too large to compact - context exceeds model limit even after stripping media", + message: replay + ? "Conversation history too large to compact - exceeds model context limit" + : "Session too large to compact - context exceeds model limit even after stripping media", }).toObject() processor.message.finish = "error" await Session.updateMessage(processor.message) @@ -210,32 +234,59 @@ When constructing the summary, try to stick to this template: } if (result === "continue" && input.auto) { - const continueMsg = await Session.updateMessage({ - id: Identifier.ascending("message"), - role: "user", - sessionID: input.sessionID, - time: { - created: Date.now(), - }, - agent: userMessage.agent, - model: userMessage.model, - }) - const text = - (input.overflow - ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" - : "") + "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." - await Session.updatePart({ - id: Identifier.ascending("part"), - messageID: continueMsg.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text, - time: { - start: Date.now(), - end: Date.now(), - }, - }) + if (replay) { + const original = replay.info as MessageV2.User + const replayMsg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + agent: original.agent, + model: original.model, + format: original.format, + tools: original.tools, + system: original.system, + variant: original.variant, + }) + for (const part of replay.parts) { + if (part.type === "compaction") continue + const replayPart = + part.type === "file" && MessageV2.isMedia(part.mime) + ? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` } + : part + await Session.updatePart({ + ...replayPart, + id: Identifier.ascending("part"), + messageID: replayMsg.id, + sessionID: input.sessionID, + }) + } + } else { + const continueMsg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + agent: userMessage.agent, + model: userMessage.model, + }) + const text = + (input.overflow + ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" + : "") + "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: continueMsg.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text, + time: { + start: Date.now(), + end: Date.now(), + }, + }) + } } if (processor.message.error) return "stop" Bus.publish(Event.Compacted, { sessionID: input.sessionID }) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 9281a29c4bb..5b4e7bdbc04 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -17,6 +17,10 @@ import { type SystemError } from "bun" import type { Provider } from "@/provider/provider" export namespace MessageV2 { + export function isMedia(mime: string) { + return mime.startsWith("image/") || mime === "application/pdf" + } + export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) export const StructuredOutputError = NamedError.create( @@ -568,8 +572,7 @@ export namespace MessageV2 { }) // 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") { - const isMedia = part.mime.startsWith("image/") || part.mime === "application/pdf" - if (options?.stripMedia && isMedia) { + if (options?.stripMedia && isMedia(part.mime)) { userMessage.parts.push({ type: "text", text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`, @@ -636,10 +639,8 @@ export namespace MessageV2 { // For providers that don't support media in tool results, extract media files // (images, PDFs) to be sent as a separate user message - const isMediaAttachment = (a: { mime: string }) => - a.mime.startsWith("image/") || a.mime === "application/pdf" - const mediaAttachments = attachments.filter(isMediaAttachment) - const nonMediaAttachments = attachments.filter((a) => !isMediaAttachment(a)) + const mediaAttachments = attachments.filter((a) => isMedia(a.mime)) + const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime)) if (!supportsMediaInToolResults && mediaAttachments.length > 0) { media.push(...mediaAttachments) } @@ -816,7 +817,8 @@ export namespace MessageV2 { msg.parts.some((part) => part.type === "compaction") ) break - if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish) completed.add(msg.info.parentID) + if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error) + completed.add(msg.info.parentID) } result.reverse() return result diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f85dc0b46fe..67edc0ecfe3 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -279,7 +279,10 @@ export namespace SessionProcessor { sessionID: input.sessionID, messageID: input.assistantMessage.parentID, }) - if (await SessionCompaction.isOverflow({ tokens: usage.tokens, model: input.model })) { + if ( + !input.assistantMessage.summary && + (await SessionCompaction.isOverflow({ tokens: usage.tokens, model: input.model })) + ) { needsCompaction = true } break