diff --git a/CHANGELOG.md b/CHANGELOG.md index a5f875d81..59374152c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,7 @@ Status: unreleased. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- Agents: prevent retries on oversized image errors and surface size limits. (#2871) Thanks @Suksham-sharma. - Agents: inherit provider baseUrl/api for inline models. (#2740) Thanks @lploc94. - Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355. - macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee. diff --git a/src/agents/pi-embedded-helpers.classifyfailoverreason.test.ts b/src/agents/pi-embedded-helpers.classifyfailoverreason.test.ts index bb449a6e4..749a52414 100644 --- a/src/agents/pi-embedded-helpers.classifyfailoverreason.test.ts +++ b/src/agents/pi-embedded-helpers.classifyfailoverreason.test.ts @@ -31,6 +31,7 @@ describe("classifyFailoverReason", () => { "messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels", ), ).toBeNull(); + expect(classifyFailoverReason("image exceeds 5 MB maximum")).toBeNull(); }); it("classifies OpenAI usage limit errors as rate_limit", () => { expect(classifyFailoverReason("You have hit your ChatGPT usage limit (plus plan)")).toBe( diff --git a/src/agents/pi-embedded-helpers.image-size-error.test.ts b/src/agents/pi-embedded-helpers.image-size-error.test.ts new file mode 100644 index 000000000..75b165d8d --- /dev/null +++ b/src/agents/pi-embedded-helpers.image-size-error.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { parseImageSizeError } from "./pi-embedded-helpers.js"; + +describe("parseImageSizeError", () => { + it("parses max MB values from error text", () => { + expect(parseImageSizeError("image exceeds 5 MB maximum")?.maxMb).toBe(5); + expect(parseImageSizeError("Image exceeds 5.5 MB limit")?.maxMb).toBe(5.5); + }); + + it("returns null for unrelated errors", () => { + expect(parseImageSizeError("context overflow")).toBeNull(); + }); +}); diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 6f6bb474f..88443756f 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -23,12 +23,14 @@ export { isFailoverAssistantError, isFailoverErrorMessage, isImageDimensionErrorMessage, + isImageSizeError, isOverloadedErrorMessage, isRawApiErrorPayload, isRateLimitAssistantError, isRateLimitErrorMessage, isTimeoutErrorMessage, parseImageDimensionError, + parseImageSizeError, } from "./pi-embedded-helpers/errors.js"; export { isGoogleModelApi, sanitizeGoogleTurnOrdering } from "./pi-embedded-helpers/google.js"; diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index d6e33f924..849c4293e 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -401,6 +401,7 @@ const ERROR_PATTERNS = { const IMAGE_DIMENSION_ERROR_RE = /image dimensions exceed max allowed size for many-image requests:\s*(\d+)\s*pixels/i; const IMAGE_DIMENSION_PATH_RE = /messages\.(\d+)\.content\.(\d+)\.image/i; +const IMAGE_SIZE_ERROR_RE = /image exceeds\s*(\d+(?:\.\d+)?)\s*mb/i; function matchesErrorPatterns(raw: string, patterns: readonly ErrorPattern[]): boolean { if (!raw) return false; @@ -467,6 +468,25 @@ export function isImageDimensionErrorMessage(raw: string): boolean { return Boolean(parseImageDimensionError(raw)); } +export function parseImageSizeError(raw: string): { + maxMb?: number; + raw: string; +} | null { + if (!raw) return null; + const lower = raw.toLowerCase(); + if (!lower.includes("image exceeds") || !lower.includes("mb")) return null; + const match = raw.match(IMAGE_SIZE_ERROR_RE); + return { + maxMb: match?.[1] ? Number.parseFloat(match[1]) : undefined, + raw, + }; +} + +export function isImageSizeError(errorMessage?: string): boolean { + if (!errorMessage) return false; + return Boolean(parseImageSizeError(errorMessage)); +} + export function isCloudCodeAssistFormatError(raw: string): boolean { return !isImageDimensionErrorMessage(raw) && matchesErrorPatterns(raw, ERROR_PATTERNS.format); } @@ -478,6 +498,7 @@ export function isAuthAssistantError(msg: AssistantMessage | undefined): boolean export function classifyFailoverReason(raw: string): FailoverReason | null { if (isImageDimensionErrorMessage(raw)) return null; + if (isImageSizeError(raw)) return null; if (isRateLimitErrorMessage(raw)) return "rate_limit"; if (isOverloadedErrorMessage(raw)) return "rate_limit"; if (isCloudCodeAssistFormatError(raw)) return "format"; diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 69eb1514a..870453f38 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -34,6 +34,7 @@ import { isContextOverflowError, isFailoverAssistantError, isFailoverErrorMessage, + parseImageSizeError, parseImageDimensionError, isRateLimitAssistantError, isTimeoutErrorMessage, @@ -440,6 +441,34 @@ export async function runEmbeddedPiAgent( }, }; } + // Handle image size errors with a user-friendly message (no retry needed) + const imageSizeError = parseImageSizeError(errorText); + if (imageSizeError) { + const maxMb = imageSizeError.maxMb; + const maxMbLabel = + typeof maxMb === "number" && Number.isFinite(maxMb) ? `${maxMb}` : null; + const maxBytesHint = maxMbLabel ? ` (max ${maxMbLabel}MB)` : ""; + return { + payloads: [ + { + text: + `Image too large for the model${maxBytesHint}. ` + + "Please compress or resize the image and try again.", + isError: true, + }, + ], + meta: { + durationMs: Date.now() - started, + agentMeta: { + sessionId: sessionIdUsed, + provider, + model: model.id, + }, + systemPromptReport: attempt.systemPromptReport, + error: { kind: "image_size", message: errorText }, + }, + }; + } const promptFailoverReason = classifyFailoverReason(errorText); if (promptFailoverReason && promptFailoverReason !== "timeout" && lastProfileId) { await markAuthProfileFailure({ diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index 4be395bce..27ccfa64e 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -20,7 +20,7 @@ export type EmbeddedPiRunMeta = { aborted?: boolean; systemPromptReport?: SessionSystemPromptReport; error?: { - kind: "context_overflow" | "compaction_failure" | "role_ordering"; + kind: "context_overflow" | "compaction_failure" | "role_ordering" | "image_size"; message: string; }; /** Stop reason for the agent run (e.g., "completed", "tool_calls"). */