Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ 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, type CursorSizeContext } from "./cursor/cursor-errors";
import { isCursorBenignCancelError, isCursorInvalidArgumentError, isCursorRootEnvelopeError, 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";
Expand Down Expand Up @@ -70,6 +70,14 @@ function safeCursorTransportError(err: unknown, sizeContext?: CursorSizeContext)
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.";
}
// A locally raised envelope rejection is already safe, specific, and actionable: it was composed
// here from our own measurements and contains no upstream text. Passing it through
// `safeCursorErrorMessage` would collapse it to the bare label "Cursor invalid request" (it
// matches the "invalid"/"exceeds" keyword branch) and discard the counts that tell the operator
// which limit was hit and by how much.
if (isCursorRootEnvelopeError(err)) {
return err instanceof Error ? `Cursor invalid request: ${err.message}` : "Cursor invalid request";
}
const message = err instanceof Error ? err.message : typeof err === "string" ? err : undefined;
if (message) return safeCursorErrorMessage(message, sizeContext);
return "Cursor upstream error: transport failed before completion.";
Expand Down Expand Up @@ -485,6 +493,12 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
...(isTranslatorBudgetExceededError(err)
? { status: 502, errorType: "upstream_error", code: "translation_buffer_limit" }
: {}),
// A local envelope rejection is a client error with a stable code, and the caller needs
// that code to distinguish "this conversation cannot be sent" from a transient upstream
// fault. Without this the class was flattened to a bare message and the code was lost.
...(isCursorRootEnvelopeError(err)
? { status: 400, errorType: "invalid_request_error", code: "cursor_root_envelope_limit", retryable: false }
: {}),
...(partialUsage ? { usage: partialUsage } : {}),
});
}
Expand Down
44 changes: 44 additions & 0 deletions src/adapters/cursor/cursor-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,50 @@ export class CursorUnexpectedCancelError extends Error {
}
}

/**
* The assembled root-blob envelope exceeded what Cursor's external workers accept.
*
* Raised locally, BEFORE the request is sent. Cursor rejects an oversized replay set with a late
* `invalid_argument` only after hydrating every blob, so the upstream failure arrives with no
* usable measurement — that is why this carries the counts it measured. Never retryable: replaying
* the same over-envelope request reproduces it exactly.
*
* The measurement is taken on the FINAL root set, after checkpoint and suffix assembly. Bounding
* an intermediate set is what let 192 checkpoint roots plus a two-root suffix emit 194 (#1527).
*/
export class CursorRootEnvelopeLimitError extends Error {
public readonly code = "cursor_root_envelope_limit";
public readonly status = 400;
Comment on lines +85 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the envelope error's structured fields

For every request rejected by this new error, src/adapters/cursor.ts catches it and emits only safeCursorTransportError(...); it never copies status, code, or retryable into the adapter error event. Message inference happens to recover HTTP 400, but clients receive the generic invalid_request_error code and no authoritative retryable: false, making the stable cursor_root_envelope_limit identity declared here unreachable. Add typed handling in the Cursor adapter catch and emit the structured fields.

AGENTS.md reference: src/AGENTS.md:L17-L19

Useful? React with 👍 / 👎.


constructor(
public readonly rootCount: number,
public readonly rootBytes: number,
public readonly maxRootCount: number,
public readonly maxRootBytes: number,
) {
super(
// No "Cursor invalid request:" prefix here: `safeCursorErrorMessage` adds it for
// invalid-argument classes, and carrying it in the message produced it twice.
"the assembled conversation exceeds the replay envelope "
+ `(${rootCount} root blobs / ${rootBytes} bytes against a limit of ${maxRootCount} / ${maxRootBytes}). `
+ "Start a new conversation or reduce the pending tool output.",
);
this.name = "CursorRootEnvelopeLimitError";
}
}

/**
* The envelope failure is local and deterministic; a retry cannot change the outcome.
*
* A companion `CursorRootMeasurementError` was drafted for the case where a root blob's size
* cannot be read, then deleted: an unmeasurable root is legitimate (a resumed conversation
* references ids Cursor minted and this process never stored), so the guard counts it instead of
* failing. There is no reachable second case, and an unreachable error class cannot be tested.
*/
export function isCursorRootEnvelopeError(value: unknown): boolean {
return value instanceof CursorRootEnvelopeLimitError;
}

export function isCursorBenignCancelError(value: unknown): boolean {
// An unexpected cancel is never benign, however it is spelled. This class is raised only when
// the transport knows WE did not request the cancel, so its provenance outranks the code match
Expand Down
13 changes: 13 additions & 0 deletions src/adapters/cursor/native-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,19 @@ export function storeCursorBlob(data: Uint8Array, requestScope?: CursorBlobReque
return blobId;
}

/**
* Stored byte length of one blob, or null when it is not in the store.
*
* Size only, never content: the envelope guard needs to measure the FINAL root set, which mixes
* roots minted this turn with roots carried inside a checkpoint. Reading them back through a
* hydration path would both defeat the request-scope sealing and log served bytes for a request
* that may never be sent.
*/
export function cursorBlobByteLength(blobId: Uint8Array): number | null {
const entry = blobs.get(key(blobId));
return entry ? entry.data.byteLength : null;
}

/**
* Serve-time integrity for content-addressed blobs (devlog 260826_cursor_responses_gap 080):
* a raw 32-byte blob id IS the SHA-256 of its bytes, so served data whose digest mismatches
Expand Down
132 changes: 130 additions & 2 deletions src/adapters/cursor/protobuf-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import { normalizeCursorToolResultText } from "./tool-result-normalize";
import { debugProviderDiagnostic } from "../../lib/debug";
import {
createCursorBlobRequestScope,
cursorBlobByteLength,
cursorBlobMaxEntryBytes,
releaseCursorBlobRequestScope,
sealCursorBlobRequestScope,
storeCursorBlob,
type CursorBlobRequestScopeToken,
} from "./native-exec";
import { CursorRootEnvelopeLimitError } from "./cursor-errors";
import { buildSelectedContext, CURSOR_VISION_IMAGE_HISTORY_MARKER } from "./images";
import { estimateTokens } from "../../lib/token-estimate";
import { parseDataUrl } from "../image";
Expand Down Expand Up @@ -325,6 +327,23 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
.map(entry => truncateToolResultBlob(entry, historyBudget))
.filter((entry): entry is RootBlobCandidate => entry !== null);
let activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0);
// Shrink every active result toward an equal share before dropping any of them. Review found
// that the previous `active.shift()` loop DELETED whole results: three ~220 KB results emitted
// only the last two, and `call_0` vanished with its tool call still in the transcript. A
// missing result is worse than a truncated one — the model sees a call it never got an answer
// to, which is the pairing break #1527 reports, and the caller cannot tell it happened.
if (active.length > 1 && activeBytes > historyBudget) {
const share = Math.floor(historyBudget / active.length);
for (let index = 0; index < active.length; index++) {
const entry = active[index];
if (!entry || entry.byteLength <= share) continue;
const shrunk = truncateToolResultBlob(entry, share);
if (shrunk) active[index] = shrunk;
}
activeBytes = active.reduce((sum, entry) => sum + entry.byteLength, 0);
}
// Only when even an equal share cannot fit — the marker alone has a floor, so enough results
// still overflow — fall back to dropping the oldest.
while (active.length > 1 && activeBytes > historyBudget) {
const dropped = active.shift();
activeBytes -= dropped?.byteLength ?? 0;
Expand All @@ -347,6 +366,9 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
let i = prior.length - 1;
while (i >= 0 && keptPrior.length + active.length < historyLimit) {
let turnStart = i;
// Root-blob roles are a closed set of four (system, user, assistant, toolResult): a
// developer message is normalized to a user root upstream, so "user" IS the turn start.
// Review suspected a developer-role gap here; the type says it cannot occur.
while (turnStart > 0 && prior[turnStart]?.role !== "user") turnStart -= 1;
const turn = prior.slice(turnStart, i + 1);
const turnBytes = turn.reduce((sum, entry) => sum + entry.byteLength, 0);
Expand All @@ -368,6 +390,73 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
if (historyEntries.length <= active.length) break;
historyEntries.shift();
}
// #1527: the surviving history must not begin with a tool result. Byte pressure can consume the
// whole budget with one large active result and drop the user turn that asked for it, and
// `conversationTurns()` then discards the result too for lack of a current turn — the wire
// request becomes system roots plus a bare result marker with no instruction, which a model
// answers in a handful of tokens.
//
// Recover the initiating root and pay for it out of the tool-result text instead.
//
// This needs no full-replay/checkpoint distinction, which is worth stating because the plan
// called for one. `activeStart > 0` confines the search to entries present in THIS call's
// history, so a checkpoint suffix can only ever recover a turn from inside its own uncovered
// slice — never one the checkpoint already carries. And for a suffix that does contain its
// initiating turn, recovery is exactly as necessary as it is for a full replay: mode-gating it
// would have recreated this defect for checkpoint continuations. Mutation testing found that;
// the mode flag could not be made to fail a test because it was never load-bearing.
if (
historyEntries.length > 0
&& historyEntries[0]?.role === "toolResult"
&& activeStart > 0
) {
let initiatorIndex = activeStart - 1;
while (initiatorIndex >= 0 && history[initiatorIndex]?.role !== "user") {
initiatorIndex -= 1;
}
const initiator = initiatorIndex >= 0 ? history[initiatorIndex] : undefined;
if (initiator) {
const withInitiator = [initiator, ...historyEntries];
const initiatorBytes = withInitiator.reduce((sum, entry) => sum + entry.byteLength, 0);
if (withInitiator.length <= historyLimit && initiatorBytes <= historyBudget) {
historyEntries.length = 0;
historyEntries.push(...withInitiator);
} else {
// Make room for the initiator instead of abandoning it. Review found that gating this on
// `historyEntries.length === 1` left the defect fully intact for the far more common
// multi-result shape: one system root plus 191 small trailing results already fills the
// count limit, so the initiator did not fit, the single-result branch did not apply, and
// the request went out as 191 bare results with nothing asking for them — inside the new
// envelope, so the guard could not catch it either.
//
// Drop the OLDEST results first, which is the same direction the byte-pressure loop above
// already prunes, then truncate whatever survives. An instruction with fewer or shorter
// results is answerable; results with no instruction are not.
const kept = [...historyEntries];
while (kept.length > 1 && kept.length + 1 > historyLimit) kept.shift();
let keptBytes = kept.reduce((sum, entry) => sum + entry.byteLength, 0);
while (kept.length > 1 && initiator.byteLength + keptBytes > historyBudget) {
const dropped = kept.shift();
keptBytes -= dropped?.byteLength ?? 0;
}
if (kept.length === 1 && kept[0] && initiator.byteLength + keptBytes > historyBudget) {
const room = historyBudget - initiator.byteLength;
const shrunk = room > 0 ? truncateToolResultBlob(kept[0], room) : null;
if (shrunk) {
kept[0] = shrunk;
keptBytes = shrunk.byteLength;
}
}
// Only commit when the initiator genuinely fits alongside what is left. If the system
// prompt has consumed the budget so completely that not even a truncation marker fits,
// there is nothing honest to send here; the envelope guard downstream owns that case.
if (kept.length + 1 <= historyLimit && initiator.byteLength + keptBytes <= historyBudget) {
historyEntries.length = 0;
historyEntries.push(initiator, ...kept);
}
}
}
}
selected = [...systemEntries, ...historyEntries];
const firstKept = historyEntries.find(entry => entry.messageIndex !== undefined);
historyMessageStart = firstKept?.messageIndex ?? (messages.length);
Expand Down Expand Up @@ -976,6 +1065,40 @@ function buildPreparedCursorRunRequest(
// filtered definitions the wire carries. Both helpers are pure.
const visibleTools = cursorToolsForActivePrompt(request.tools, rawText, request.toolChoice);
const mcpToolDefs = buildCursorToolDefinitions(visibleTools, request.toolChoice);
// The envelope is measured HERE, on the final root set, and nowhere else.
//
// `rootPromptMessages` cannot do it: it sees only a checkpoint suffix, so 192 checkpoint roots
// plus a two-root suffix passed its per-call check and emitted 194 roots; and its empty-history
// early return skips the pruning branch entirely, which let 193 system prompts through. Both are
// downstream of this point, which is the first place the wire content is fully known (#1527).
//
// The same measurement feeds the diagnostic below, so telemetry cannot disagree with the guard.
//
// Roots carried inside a decoded checkpoint need not be in the local store — Cursor minted some
// of them, and a resumed conversation legitimately references ids this process never wrote. So an
// unmeasurable root is counted, not fatal: the COUNT limit still binds it (that is the 194-root
// case), and `unmeasuredRoots` records that the byte total is a floor rather than a total. An
// earlier fail-closed version broke three passing checkpoint tests, which is the evidence that
// failing closed here would reject working continuation.
const measuredRootCount = conversationState.rootPromptMessagesJson.length;
let measuredRootBytes = 0;
let unmeasuredRoots = 0;
for (const blobId of conversationState.rootPromptMessagesJson) {
const size = cursorBlobByteLength(blobId);
if (size === null) unmeasuredRoots += 1;
else measuredRootBytes += size;
}
if (
isCursorExternalWireModel(request.modelId)
&& (measuredRootCount > CURSOR_EXTERNAL_ROOT_BLOB_LIMIT || measuredRootBytes > CURSOR_EXTERNAL_ROOT_BYTE_LIMIT)
) {
throw new CursorRootEnvelopeLimitError(
measuredRootCount,
measuredRootBytes,
CURSOR_EXTERNAL_ROOT_BLOB_LIMIT,
CURSOR_EXTERNAL_ROOT_BYTE_LIMIT,
);
}
debugProviderDiagnostic("cursor", "run-request", {
wireModel: request.modelId,
action: actionCase,
Expand All @@ -987,8 +1110,13 @@ function buildPreparedCursorRunRequest(
checkpointPresent: continuationMode === "checkpoint",
checkpointBytes: continuationMode === "checkpoint" ? request.checkpointBytes?.byteLength : undefined,
checkpointInvalidationReason,
rootBlobs: conversationState.rootPromptMessagesJson.length,
rootBytes: rootPromptMessagesState?.byteLength ?? 0,
rootBlobs: measuredRootCount,
// Was `rootPromptMessagesState?.byteLength ?? 0`, which reported 0 for a pure checkpoint and,
// for a suffix, counted a synthetic system root that had already been sliced off.
rootBytes: measuredRootBytes,
// Non-zero means rootBytes is a floor: that many roots came from a checkpoint the local store
// never held. Recorded rather than hidden, so an operator reading the number knows which it is.
...(unmeasuredRoots > 0 ? { unmeasuredRoots } : {}),
turnBlobs: conversationState.turns.length,
tools: request.tools?.length ?? 0,
});
Expand Down
6 changes: 5 additions & 1 deletion src/adapters/cursor/transport-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { CursorRunRequest, CursorServerMessage } from "./types";
import type { CursorTransport, CursorTransportFactory, CursorTransportFactoryInput } from "./transport";
import { abortError, retryBackoffDelayMs, sleepWithAbort } from "../../lib/upstream-retry";
import { debugProviderDiagnostic } from "../../lib/debug";
import { safeCursorErrorMessage } from "./cursor-errors";
import { isCursorRootEnvelopeError, safeCursorErrorMessage } from "./cursor-errors";

// Compat: historical name for the shared abortable sleep, kept for external callers.
export { sleepWithAbort as abortAwareSleep } from "../../lib/upstream-retry";
Expand All @@ -18,6 +18,10 @@ export const CURSOR_RETRY_MAX_MS = 2_000;
* never replay a turn the Cursor server might already have accepted.
*/
export function isRetryableCursorError(err: unknown): boolean {
// Local envelope failures are deterministic: the same request produces the same rejection.
// Checked before the text heuristics below, which would otherwise have to infer this from
// wording.
if (isCursorRootEnvelopeError(err)) return false;
const code = typeof err === "object" && err && "code" in err ? String((err as { code?: unknown }).code ?? "") : "";
const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
const haystack = `${code} ${message}`.toLowerCase();
Expand Down
Loading
Loading