fix: recover from 413 Request Entity Too Large via auto-compaction (#14707)

Co-authored-by: Noam Bressler <noamzbr@gmail.com>
This commit is contained in:
bentrd
2026-03-02 13:10:55 +05:30
committed by GitHub
co-authored by Noam Bressler
parent 7bfbb1fcf8
commit be20f865ac
5 changed files with 152 additions and 58 deletions
+2 -1
View File
@@ -19,6 +19,7 @@ export namespace ProviderError {
/context window exceeds limit/i, // MiniMax /context window exceeds limit/i, // MiniMax
/exceeded model token limit/i, // Kimi For Coding, Moonshot /exceeded model token limit/i, // Kimi For Coding, Moonshot
/context[_ ]length[_ ]exceeded/i, // Generic fallback /context[_ ]length[_ ]exceeded/i, // Generic fallback
/request entity too large/i, // HTTP 413
] ]
function isOpenAiErrorRetryable(e: APICallError) { function isOpenAiErrorRetryable(e: APICallError) {
@@ -177,7 +178,7 @@ export namespace ProviderError {
export function parseAPICallError(input: { providerID: string; error: APICallError }): ParsedAPICallError { export function parseAPICallError(input: { providerID: string; error: APICallError }): ParsedAPICallError {
const m = message(input.providerID, input.error) const m = message(input.providerID, input.error)
if (isOverflow(m)) { if (isOverflow(m) || input.error.statusCode === 413) {
return { return {
type: "context_overflow", type: "context_overflow",
message: m, message: m,
+90 -23
View File
@@ -104,8 +104,31 @@ export namespace SessionCompaction {
sessionID: string sessionID: string
abort: AbortSignal abort: AbortSignal
auto: boolean auto: boolean
overflow?: boolean
}) { }) {
const userMessage = input.messages.findLast((m) => m.info.id === input.parentID)!.info as MessageV2.User 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 agent = await Agent.get("compaction")
const model = agent.model const model = agent.model
? await Provider.getModel(agent.model.providerID, agent.model.modelID) ? await Provider.getModel(agent.model.providerID, agent.model.modelID)
@@ -185,7 +208,7 @@ When constructing the summary, try to stick to this template:
tools: {}, tools: {},
system: [], system: [],
messages: [ messages: [
...MessageV2.toModelMessages(input.messages, model), ...MessageV2.toModelMessages(messages, model, { stripMedia: true }),
{ {
role: "user", role: "user",
content: [ content: [
@@ -199,29 +222,71 @@ When constructing the summary, try to stick to this template:
model, model,
}) })
if (result === "compact") {
processor.message.error = new MessageV2.ContextOverflowError({
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)
return "stop"
}
if (result === "continue" && input.auto) { if (result === "continue" && input.auto) {
const continueMsg = await Session.updateMessage({ if (replay) {
id: Identifier.ascending("message"), const original = replay.info as MessageV2.User
role: "user", const replayMsg = await Session.updateMessage({
sessionID: input.sessionID, id: Identifier.ascending("message"),
time: { role: "user",
created: Date.now(), sessionID: input.sessionID,
}, time: { created: Date.now() },
agent: userMessage.agent, agent: original.agent,
model: userMessage.model, model: original.model,
}) format: original.format,
await Session.updatePart({ tools: original.tools,
id: Identifier.ascending("part"), system: original.system,
messageID: continueMsg.id, variant: original.variant,
sessionID: input.sessionID, })
type: "text", for (const part of replay.parts) {
synthetic: true, if (part.type === "compaction") continue
text: "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.", const replayPart =
time: { part.type === "file" && MessageV2.isMedia(part.mime)
start: Date.now(), ? { type: "text" as const, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]` }
end: Date.now(), : 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" if (processor.message.error) return "stop"
Bus.publish(Event.Compacted, { sessionID: input.sessionID }) Bus.publish(Event.Compacted, { sessionID: input.sessionID })
@@ -237,6 +302,7 @@ When constructing the summary, try to stick to this template:
modelID: z.string(), modelID: z.string(),
}), }),
auto: z.boolean(), auto: z.boolean(),
overflow: z.boolean().optional(),
}), }),
async (input) => { async (input) => {
const msg = await Session.updateMessage({ const msg = await Session.updateMessage({
@@ -255,6 +321,7 @@ When constructing the summary, try to stick to this template:
sessionID: msg.sessionID, sessionID: msg.sessionID,
type: "compaction", type: "compaction",
auto: input.auto, auto: input.auto,
overflow: input.overflow,
}) })
}, },
) )
+30 -14
View File
@@ -17,6 +17,10 @@ import { type SystemError } from "bun"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
export namespace MessageV2 { 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 OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() }))
export const StructuredOutputError = NamedError.create( export const StructuredOutputError = NamedError.create(
@@ -196,6 +200,7 @@ export namespace MessageV2 {
export const CompactionPart = PartBase.extend({ export const CompactionPart = PartBase.extend({
type: z.literal("compaction"), type: z.literal("compaction"),
auto: z.boolean(), auto: z.boolean(),
overflow: z.boolean().optional(),
}).meta({ }).meta({
ref: "CompactionPart", ref: "CompactionPart",
}) })
@@ -488,7 +493,11 @@ export namespace MessageV2 {
}) })
export type WithParts = z.infer<typeof WithParts> export type WithParts = z.infer<typeof WithParts>
export function toModelMessages(input: WithParts[], model: Provider.Model): ModelMessage[] { export function toModelMessages(
input: WithParts[],
model: Provider.Model,
options?: { stripMedia?: boolean },
): ModelMessage[] {
const result: UIMessage[] = [] const result: UIMessage[] = []
const toolNames = new Set<string>() const toolNames = new Set<string>()
// Track media from tool results that need to be injected as user messages // Track media from tool results that need to be injected as user messages
@@ -562,13 +571,21 @@ export namespace MessageV2 {
text: part.text, text: part.text,
}) })
// text/plain and directory files are converted into text parts, ignore them // 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 (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") {
userMessage.parts.push({ if (options?.stripMedia && isMedia(part.mime)) {
type: "file", userMessage.parts.push({
url: part.url, type: "text",
mediaType: part.mime, text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`,
filename: part.filename, })
}) } else {
userMessage.parts.push({
type: "file",
url: part.url,
mediaType: part.mime,
filename: part.filename,
})
}
}
if (part.type === "compaction") { if (part.type === "compaction") {
userMessage.parts.push({ userMessage.parts.push({
@@ -618,14 +635,12 @@ export namespace MessageV2 {
toolNames.add(part.tool) toolNames.add(part.tool)
if (part.state.status === "completed") { if (part.state.status === "completed") {
const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output
const attachments = part.state.time.compacted ? [] : (part.state.attachments ?? []) const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])
// For providers that don't support media in tool results, extract media files // For providers that don't support media in tool results, extract media files
// (images, PDFs) to be sent as a separate user message // (images, PDFs) to be sent as a separate user message
const isMediaAttachment = (a: { mime: string }) => const mediaAttachments = attachments.filter((a) => isMedia(a.mime))
a.mime.startsWith("image/") || a.mime === "application/pdf" const nonMediaAttachments = attachments.filter((a) => !isMedia(a.mime))
const mediaAttachments = attachments.filter(isMediaAttachment)
const nonMediaAttachments = attachments.filter((a) => !isMediaAttachment(a))
if (!supportsMediaInToolResults && mediaAttachments.length > 0) { if (!supportsMediaInToolResults && mediaAttachments.length > 0) {
media.push(...mediaAttachments) media.push(...mediaAttachments)
} }
@@ -802,7 +817,8 @@ export namespace MessageV2 {
msg.parts.some((part) => part.type === "compaction") msg.parts.some((part) => part.type === "compaction")
) )
break 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() result.reverse()
return result return result
+28 -20
View File
@@ -279,7 +279,10 @@ export namespace SessionProcessor {
sessionID: input.sessionID, sessionID: input.sessionID,
messageID: input.assistantMessage.parentID, 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 needsCompaction = true
} }
break break
@@ -354,27 +357,32 @@ export namespace SessionProcessor {
}) })
const error = MessageV2.fromError(e, { providerID: input.model.providerID }) const error = MessageV2.fromError(e, { providerID: input.model.providerID })
if (MessageV2.ContextOverflowError.isInstance(error)) { if (MessageV2.ContextOverflowError.isInstance(error)) {
// TODO: Handle context overflow error needsCompaction = true
} Bus.publish(Session.Event.Error, {
const retry = SessionRetry.retryable(error) sessionID: input.sessionID,
if (retry !== undefined) { error,
attempt++
const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
SessionStatus.set(input.sessionID, {
type: "retry",
attempt,
message: retry,
next: Date.now() + delay,
}) })
await SessionRetry.sleep(delay, input.abort).catch(() => {}) } else {
continue const retry = SessionRetry.retryable(error)
if (retry !== undefined) {
attempt++
const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined)
SessionStatus.set(input.sessionID, {
type: "retry",
attempt,
message: retry,
next: Date.now() + delay,
})
await SessionRetry.sleep(delay, input.abort).catch(() => {})
continue
}
input.assistantMessage.error = error
Bus.publish(Session.Event.Error, {
sessionID: input.assistantMessage.sessionID,
error: input.assistantMessage.error,
})
SessionStatus.set(input.sessionID, { type: "idle" })
} }
input.assistantMessage.error = error
Bus.publish(Session.Event.Error, {
sessionID: input.assistantMessage.sessionID,
error: input.assistantMessage.error,
})
SessionStatus.set(input.sessionID, { type: "idle" })
} }
if (snapshot) { if (snapshot) {
const patch = await Snapshot.patch(snapshot) const patch = await Snapshot.patch(snapshot)
+2
View File
@@ -533,6 +533,7 @@ export namespace SessionPrompt {
abort, abort,
sessionID, sessionID,
auto: task.auto, auto: task.auto,
overflow: task.overflow,
}) })
if (result === "stop") break if (result === "stop") break
continue continue
@@ -707,6 +708,7 @@ export namespace SessionPrompt {
agent: lastUser.agent, agent: lastUser.agent,
model: lastUser.model, model: lastUser.model,
auto: true, auto: true,
overflow: !processor.message.finish,
}) })
} }
continue continue