diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index a6cb5cbf9d..4089e81ed3 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -3,8 +3,8 @@ import type { AdapterEvent, OcxProviderConfig } from "../types"; import type { ProviderAdapter } from "./base"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy"; -import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage } from "./cursor/cursor-errors"; -import { cursorCheckpointModelAffinityId, isCursorExternalWireModel } from "./cursor/discovery"; +import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors"; +import { cursorCheckpointModelAffinityId, inferCursorContextWindow, isCursorExternalWireModel } from "./cursor/discovery"; import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; import { @@ -25,6 +25,7 @@ import { invalidateCursorCheckpoint, } from "./cursor/checkpoint-store"; import { debugProviderDiagnostic } from "../lib/debug"; +import { estimateTokens } from "../lib/token-estimate"; import { rememberCursorThreadConversation } from "./cursor/thread-continuity"; import { runCursorTurnWithRetry } from "./cursor/transport-retry"; import { @@ -53,16 +54,29 @@ export interface CursorAdapterDeps { rekeyContextUsage?: (fromConversationId: string, toConversationId: string) => void; } -function safeCursorTransportError(err: unknown): string { +function safeCursorTransportError(err: unknown, sizeContext?: CursorSizeContext): string { if (err instanceof CursorTransportDisabledError) return CURSOR_TRANSPORT_DISABLED_MESSAGE; if (err instanceof CursorMissingCredentialError) { return "Cursor live transport is enabled, but no Cursor access token is configured. Set provider.apiKey or OPENCODEX_CURSOR_TEST_TOKEN."; } const message = err instanceof Error ? err.message : typeof err === "string" ? err : undefined; - if (message) return safeCursorErrorMessage(message); + if (message) return safeCursorErrorMessage(message, sizeContext); return "Cursor upstream error: transport failed before completion."; } +/** + * Size prior for bare resource_exhausted classification (devlog 260): a rough input + * estimate over the outgoing text vs the model's context window. Only used to keep + * SMALL requests on the 429 class — unknown/large stays on the overflow mapping. + */ +function cursorRequestSizeContext(request: { modelId: string; system: string[]; messages: { content: string }[] }): CursorSizeContext { + const text = [...request.system, ...request.messages.map(message => message.content)].join("\n"); + return { + estimatedInputTokens: estimateTokens(text, request.modelId), + contextWindow: inferCursorContextWindow(request.modelId), + }; +} + export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAdapterDeps = {}): ProviderAdapter { return { name: "cursor", @@ -88,6 +102,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda emit({ type: "error", message: "Cursor turn was aborted before start." }); return; } + // Captured after createCursorRequest so the catch block can apply the bare-RE + // size prior (devlog 260) even though `request` is scoped inside the try. + let requestSizeContext: CursorSizeContext | undefined; try { const makeTransport = deps.createTransport ?? createLiveCursorTransport; const kv = deps.kv ?? createCursorKvStore({}, incoming.translatorBudget); @@ -110,6 +127,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef; const previousConversationId = _parsed._cursorConversationId; let request = createCursorRequest(_parsed); + requestSizeContext = cursorRequestSizeContext(request); // The builder may derive a stable provider id from the client thread when Responses state // is unavailable. Rekey only existing state; there is nothing to migrate on a fresh turn, // and isolated helper/compaction turns must never inherit or donate the parent's usage state. @@ -292,7 +310,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda type: "error", message: isTranslatorBudgetExceededError(err) ? "upstream translation buffer exceeded the safe limit" - : safeCursorTransportError(err), + : safeCursorTransportError(err, requestSizeContext), ...(isTranslatorBudgetExceededError(err) ? { status: 502, errorType: "upstream_error", code: "translation_buffer_limit" } : {}), diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index 39f0fc5a8a..33ded8dc7c 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -123,6 +123,30 @@ const QUOTA_RATE_CUES = ["too many requests", "quota", "rate limit", "rate-limit */ const BARE_RE_TAILS = new Set(["error", "", "resource_exhausted", "resource exhausted"]); +/** + * Size prior for bare resource_exhausted classification (devlog 260, live probe 210): + * a plan-gated model returns the SAME bare RE shape on a ~20-token prompt that a real + * payload overflow produces, so the message alone cannot separate "compact and retry" + * from "this account cannot use this model". When the caller can supply how large the + * request actually was relative to the model's window, a small request keeps the + * 429-class mapping; only a plausibly-large one classifies as overflow. Unknown + * sizes keep today's overflow mapping so the prior only ever REMOVES false overflows + * it can prove. + */ +export interface CursorSizeContext { + estimatedInputTokens?: number; + contextWindow?: number; +} + +const OVERFLOW_MIN_FRACTION = 0.5; + +function bareReLooksLikeOverflow(context?: CursorSizeContext): boolean { + if (!context) return true; + const { estimatedInputTokens, contextWindow } = context; + if (estimatedInputTokens === undefined || contextWindow === undefined || contextWindow <= 0) return true; + return estimatedInputTokens >= OVERFLOW_MIN_FRACTION * contextWindow; +} + export function isCursorZeroTokenResourceExhausted(lowerMessage: string): boolean { if (!lowerMessage.includes("resource_exhausted") && !lowerMessage.includes("resource exhausted")) return false; // Any explicit quota/rate cue wins: this is a real 429. @@ -172,7 +196,7 @@ export function isCursorRequestTooLargeDetail(lowerMessage: string): boolean { * The returned prefix string is recognized by `src/lib/errors.ts` `classifyError` keywords, * so bridge-level error mapping produces the right Codex error type (rate_limit, auth, etc.). */ -export function classifyCursorError(message: string): string { +export function classifyCursorError(message: string, sizeContext?: CursorSizeContext): string { const lower = message.toLowerCase(); if (isCursorBenignCancelError(message)) return "Cursor stream suspended"; @@ -190,7 +214,11 @@ export function classifyCursorError(message: string): string { // A bare resource_exhausted with no quota cue and no size phrase is payload // overflow, not rate limiting. Classifying it as 429 makes Codex back off on a // failure that only compaction can fix (senpi #1009 / #1036; research unit T01). - if (isCursorZeroTokenResourceExhausted(lower)) return "Cursor context limit exceeded"; + // Refinement (devlog 260): plan-gated models emit the same bare shape on tiny + // requests — when the caller proves the request was small, keep the 429 class. + if (isCursorZeroTokenResourceExhausted(lower)) { + return bareReLooksLikeOverflow(sizeContext) ? "Cursor context limit exceeded" : "Cursor rate limit exceeded"; + } return "Cursor rate limit exceeded"; } @@ -251,8 +279,8 @@ export function classifyCursorError(message: string): string { * Produce a user-facing, secret-safe Cursor error message with an actionable category prefix. * Mirrors `safeKiroErrorMessage` / `safeKiroHttpErrorMessage` in kiro-errors.ts. */ -export function safeCursorErrorMessage(rawMessage: string): string { - const prefix = classifyCursorError(rawMessage); +export function safeCursorErrorMessage(rawMessage: string, sizeContext?: CursorSizeContext): string { + const prefix = classifyCursorError(rawMessage, sizeContext); const detail = sanitize(rawMessage) .replace(/resource[_ ]exhausted/gi, "resource limit exceeded") .slice(0, 500); diff --git a/tests/cursor-errors.test.ts b/tests/cursor-errors.test.ts index 3ae5ade2cd..59a7b95399 100644 --- a/tests/cursor-errors.test.ts +++ b/tests/cursor-errors.test.ts @@ -136,3 +136,34 @@ describe("isCursorInvalidArgumentError", () => { expect(isCursorInvalidArgumentError(new Error("Cursor connection failed"))).toBe(false); }); }); + +describe("bare resource_exhausted size prior (devlog 260)", () => { + const BARE = "Cursor Connect error resource_exhausted: Error"; + + test("a provably small request keeps the 429 class (plan-gated model, live probe 210)", () => { + expect(classifyCursorError(BARE, { estimatedInputTokens: 20, contextWindow: 200_000 })) + .toBe("Cursor rate limit exceeded"); + }); + + test("a plausibly large request still classifies as context overflow", () => { + expect(classifyCursorError(BARE, { estimatedInputTokens: 150_000, contextWindow: 200_000 })) + .toBe("Cursor context limit exceeded"); + }); + + test("unknown estimate or window keeps today's overflow mapping (prior only removes provable false overflows)", () => { + expect(classifyCursorError(BARE)).toBe("Cursor context limit exceeded"); + expect(classifyCursorError(BARE, {})).toBe("Cursor context limit exceeded"); + expect(classifyCursorError(BARE, { estimatedInputTokens: 20 })).toBe("Cursor context limit exceeded"); + expect(classifyCursorError(BARE, { contextWindow: 200_000 })).toBe("Cursor context limit exceeded"); + }); + + test("explicit quota cues stay 429 regardless of size context", () => { + expect(classifyCursorError("resource_exhausted: quota exhausted", { estimatedInputTokens: 150_000, contextWindow: 200_000 })) + .toBe("Cursor rate limit exceeded"); + }); + + test("explicit size phrases stay resource-limit regardless of size context", () => { + expect(classifyCursorError("resource_exhausted: request body exceeds maximum allowed size", { estimatedInputTokens: 20, contextWindow: 200_000 })) + .toBe("Cursor resource limit exceeded"); + }); +});