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
37 changes: 36 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, isCursorOverflowRemintCandidate, isCursorRootEnvelopeError, safeCursorErrorMessage, type CursorSizeContext } from "./cursor/cursor-errors";
import { isCursorBenignCancelError, isCursorIncompleteToolCallMessage, isCursorInvalidArgumentError, isCursorOverflowRemintCandidate, 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 @@ -32,8 +32,11 @@ import { isDebugEnabled } from "../lib/debug-settings";
import { createAdapterTierMetadata } from "../providers/fastwire";
import { estimateTokens } from "../lib/token-estimate";
import {
clearCursorIncompleteToolRemint,
cursorIncompleteToolRemintScopeKey,
cursorOverflowRemintScopeKey,
markCursorOverflowSurfaced,
recordCursorIncompleteToolRemint,
recordCursorOverflowRemint,
rememberCursorThreadConversation,
shouldSkipCursorOverflowRemint,
Expand Down Expand Up @@ -202,6 +205,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
let completedNormally = false;
let lastTransport: { captured?: Uint8Array } | undefined;
let emittedClientTool = false;
let sawIncompleteToolCall = false;
// Ordering proof for tool-suspended checkpoints: true only when the newest captured
// checkpoint bytes arrived AFTER the turn emitted a client tool call, i.e. upstream
// serialized its suspended-on-tool-call state. Only that snapshot can safely resume
Expand Down Expand Up @@ -344,6 +348,9 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
},
});
for (const event of events) {
if (event.type === "error" && isCursorIncompleteToolCallMessage(event.message)) {
sawIncompleteToolCall = true;
}
if (!guardsSettled()) {
if (event.type === "text_delta") {
guardHeld.push(event);
Expand Down Expand Up @@ -529,6 +536,34 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
}
}
}
const incompleteToolRemintScopeKey =
_parsed._cursorIsolateConversation !== true
&& request.contextUsageStoreCheckpoints !== false
? cursorIncompleteToolRemintScopeKey(
cursorClientThreadOwner(_parsed),
_parsed._cursorIdentityScope,
)
: null;
// Incomplete-tool errors are streamed, not thrown. Do not retry this turn; rotate only
// the next turn's id. request-prepare currently isolates compaction, but adapter callers
// can bypass that upstream invariant, so checkpoint storage is the local isolation boundary.
if (sawIncompleteToolCall && incompleteToolRemintScopeKey) {
if (recordCursorIncompleteToolRemint(incompleteToolRemintScopeKey)) {
if (inheritedCheckpointRef) invalidateCursorCheckpoint(inheritedCheckpointRef);
debugProviderDiagnostic("cursor", "incomplete-tool-remint", {
wireModel: request.modelId,
conversationHash: request.conversationId.slice(0, 16),
});
remintConversationId(request.conversationId);
} else {
debugProviderDiagnostic("cursor", "incomplete-tool-remint-exhausted", {
wireModel: request.modelId,
conversationHash: request.conversationId.slice(0, 16),
});
}
} else if (!sawIncompleteToolCall && completedNormally && incompleteToolRemintScopeKey) {
clearCursorIncompleteToolRemint(incompleteToolRemintScopeKey);
}
if (
request.checkpointInvalidationReason
&& request.checkpointInvalidationReason !== "missing_ref"
Expand Down
15 changes: 15 additions & 0 deletions src/adapters/cursor/cursor-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ export class CursorStreamTruncatedError extends Error {
}
}

export const CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX =
"Cursor stream ended with incomplete tool call(s):";

/**
* True when Cursor ended the stream with a client tool still open. The adapter fail-closes
* the current turn (no partial `tool_call_start`) and remints the conversation afterwards
* so the next turn does not resume a session left waiting for `mcpResult`.
*/
export function isCursorIncompleteToolCallMessage(value: unknown): boolean {
const message = typeof value === "string" ? value : errorMessage(value);
const lower = message.toLowerCase();
return lower.includes(CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX.toLowerCase())
|| lower.includes("tool call(s) left incomplete");
}

/**
* A cancel-shaped stream failure that WE did not request. `cancelCursorRun` is the only place
* that cancels our own stream, and it sets `expectedClose` first, so a cancel arriving without it
Expand Down
3 changes: 2 additions & 1 deletion src/adapters/cursor/protobuf-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { recordObservedCursorContextWindow } from "./discovery";
import type { CursorServerMessage } from "./types";
import type { TranslatorBudget } from "../../lib/translator-budget";
import { CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX } from "./cursor-errors";

const DEFAULT_CONTEXT_USAGE_MAX_ENTRIES = 200;
const DEFAULT_CONTEXT_USAGE_TTL_MS = 60 * 60 * 1_000;
Expand Down Expand Up @@ -1458,7 +1459,7 @@ export function finalizeTurnEvents(state: CursorProtobufEventState): CursorServe
// Clear so a second turnEnded (should not happen, but defensive) doesn't re-emit.
for (const callId of openCallIds) state.translatorBudget?.closeCall(callId);
state.openToolCalls.clear();
return [{ type: "error", message: `Cursor stream ended with incomplete tool call(s): ${openIds}. Arguments may be truncated; the call was not committed.` }];
return [{ type: "error", message: `${CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX} ${openIds}. Arguments may be truncated; the call was not committed.` }];
}
const out: CursorServerMessage[] = [];
if (!state.sawRealClientToolCall) {
Expand Down
20 changes: 19 additions & 1 deletion src/adapters/cursor/protobuf-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export const CURSOR_ROUTING_LEVEL_PARAMETER_ID = "optimization";
export const CURSOR_EXTERNAL_ROOT_BLOB_LIMIT = 192;
/** Approximate prompt-size guard; tool schemas and protocol framing consume context separately. */
export const CURSOR_EXTERNAL_ROOT_BYTE_LIMIT = 512 * 1024;
/** Honest placeholder when native Composer history has a toolCall with no matching toolResult. */
export const CURSOR_MISSING_TOOL_RESULT = "[missing tool_result for this tool_use in history]";
/**
* Byte budget for the serialized arguments named inside ONE replayed tool-result envelope. The
* invocation identifies the call; the result is the payload. Without an independent cap, a single
Expand Down Expand Up @@ -1218,6 +1220,20 @@ function argBytes(value: unknown): Uint8Array {
}
}

function missingToolResultFor(
part: Extract<OcxAssistantContentPart, { type: "toolCall" }>,
): OcxToolResultMessage {
return {
role: "toolResult",
toolCallId: part.id,
toolName: part.name,
...(part.namespace ? { toolNamespace: part.namespace } : {}),
content: CURSOR_MISSING_TOOL_RESULT,
isError: true,
timestamp: 0,
};
}

function toolCallStep(
part: Extract<OcxAssistantContentPart, { type: "toolCall" }>,
requestScope: CursorBlobRequestScopeToken,
Expand Down Expand Up @@ -1332,7 +1348,9 @@ function conversationTurns(
const pendingToolCalls = new Map<string, Extract<OcxAssistantContentPart, { type: "toolCall" }>>();
const flush = () => {
if (!current) return;
for (const part of pendingToolCalls.values()) current.steps.push(toolCallStep(part, requestScope));
for (const part of pendingToolCalls.values()) {
current.steps.push(toolCallStep(part, requestScope, missingToolResultFor(part), codeMode));
}
turns.push(storeCursorBlob(toBinary(ConversationTurnStructureSchema, create(ConversationTurnStructureSchema, {
turn: {
case: "agentConversationTurn",
Expand Down
67 changes: 67 additions & 0 deletions src/adapters/cursor/thread-continuity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,70 @@ export function cursorOverflowRemintCountForTests(): number {
pruneOverflowRemints(now());
return overflowRemintByScope.size;
}

/** Max next-turn conversation-id rotations after incomplete client-tool streams per retained scope. */
export const CURSOR_INCOMPLETE_TOOL_REMINT_MAX = 3;
export const CURSOR_INCOMPLETE_TOOL_REMINT_TTL_MS = CURSOR_OVERFLOW_REMINT_TTL_MS;
export const CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES = CURSOR_OVERFLOW_REMINT_MAX_ENTRIES;

type IncompleteToolRemintState = {
remintCount: number;
updatedAt: number;
};

const incompleteToolRemintByScope = new Map<string, IncompleteToolRemintState>();

function pruneIncompleteToolRemints(at: number): void {
for (const [scopeKey, entry] of incompleteToolRemintByScope) {
if (at - entry.updatedAt > CURSOR_INCOMPLETE_TOOL_REMINT_TTL_MS) {
incompleteToolRemintByScope.delete(scopeKey);
}
}
while (incompleteToolRemintByScope.size > CURSOR_INCOMPLETE_TOOL_REMINT_MAX_ENTRIES) {
const oldest = incompleteToolRemintByScope.keys().next().value;
if (oldest === undefined) break;
incompleteToolRemintByScope.delete(oldest);
}
}

/** Incomplete-tool and overflow recovery share ownership scope, but keep independent budgets. */
export function cursorIncompleteToolRemintScopeKey(
threadOwner: string | undefined,
identityScope?: string,
): string | null {
return cursorOverflowRemintScopeKey(threadOwner, identityScope);
}

/** Record one incomplete-tool remint; returns false when the independent cap is exhausted. */
export function recordCursorIncompleteToolRemint(scopeKey: string): boolean {
const at = now();
pruneIncompleteToolRemints(at);
const existing = incompleteToolRemintByScope.get(scopeKey);
if (existing && existing.remintCount >= CURSOR_INCOMPLETE_TOOL_REMINT_MAX) {
existing.updatedAt = at;
incompleteToolRemintByScope.delete(scopeKey);
incompleteToolRemintByScope.set(scopeKey, existing);
return false;
}
const entry = existing ?? { remintCount: 0, updatedAt: at };
entry.remintCount += 1;
entry.updatedAt = at;
incompleteToolRemintByScope.delete(scopeKey);
incompleteToolRemintByScope.set(scopeKey, entry);
pruneIncompleteToolRemints(at);
return true;
}

/** A clean turn replenishes this recovery without changing the overflow retry budget. */
export function clearCursorIncompleteToolRemint(scopeKey: string): void {
incompleteToolRemintByScope.delete(scopeKey);
}

export function clearCursorIncompleteToolRemintForTests(): void {
incompleteToolRemintByScope.clear();
}

export function cursorIncompleteToolRemintCountForTests(): number {
pruneIncompleteToolRemints(now());
return incompleteToolRemintByScope.size;
}
2 changes: 2 additions & 0 deletions structure/providers/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ classifies as overflow. Coverage lives in

`src/adapters/cursor.ts` surfaces the first bare context overflow before attempting conversation remint on later eligible requests. `cursorClientThreadOwner` recognizes both client thread aliases; `src/adapters/cursor/thread-continuity.ts` limits recovery to three remints per retained identity-scoped owner, with a one-hour idle TTL and 2,048-entry bound. Conversation-only requests have no stable owner and do not automatically remint. Quota/rate errors, tool-result resumes, partial output, local side effects, isolated helper/shadow requests and compaction remain fail-closed. Isolated requests neither consume the parent allowance nor invalidate its checkpoint. Eligible overflow checks refresh existing retention timestamps and LRU position even after the cap is exhausted, without allocating absent scopes. Retention expiry, eviction or process restart resets the in-memory allowance; this is not a persistent lifetime cap or semantic-progress policy.

An incomplete client-tool stream is fail-closed for the current turn: `finalizeTurnEvents` emits the prefix owned by `CURSOR_INCOMPLETE_TOOL_CALL_MESSAGE_PREFIX` and does not retry that send. After the error is streamed, eligible non-isolated turns remint the Cursor conversation id, persist the thread override, and invalidate the inherited checkpoint so the next turn does not resume a conversation left waiting for `mcpResult`. `src/adapters/cursor/thread-continuity.ts` permits three such rotations per retained identity-scoped thread owner in a separate bounded counter; exhaustion keeps reusing the conversation and records an `incomplete-tool-remint-exhausted` diagnostic, while a clean completed turn clears that scope's counter. This allowance never consumes or replenishes the overflow resend budget. Isolated helper and compaction turns neither remint nor change the parent's allowance or checkpoint. Native Composer replay synthesizes `[missing tool_result for this tool_use in history]` for unpaired `toolCallStep` history; external wire models skip native `mcpToolCall` replay, so conversation remint is their recovery path.

Translated Chat request construction uses the [inline-image budget](../transports/streaming-health.md#translated-chat-inline-image-budget); the shared normalizer counts retained bytes even when a wire-specific drop callback keeps the image attached.

Translated audio/file admission follows the [final-adapter input contract](../adapters/registry.md#untranslated-input-media); native raw passthrough remains separate.
Expand Down
Loading
Loading