Skip to content
Closed
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
56 changes: 52 additions & 4 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
import { runCursorTurnWithRetry } from "./cursor/transport-retry";
import { cursorRequestHasShellAlias, cursorRequestUsesCodeMode } from "./cursor/tool-definitions";
import {
CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES,
CURSOR_ECHO_RETRY_CONTINUATION_TEXT,
CURSOR_ROUTING_COMMENTARY_RETRY_TEXT,
CursorEnvelopeEchoSniffer,
Expand Down Expand Up @@ -315,6 +316,8 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
? new CursorRoutingCommentarySniffer()
: undefined;
let guardHeld: AdapterEvent[] = [];
let guardHeldBytes = 0;
const guardEncoder = new TextEncoder();
// Exactly-once observation: every client-bound text delta passes through here
// exactly once — held deltas only on release, ordinary deltas at emit time.
const emitTextObserved = (event: AdapterEvent): void => {
Expand All @@ -327,7 +330,49 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
emitTextObserved(held);
}
guardHeld = [];
guardHeldBytes = 0;
};
// A single frame can carry a multi-megabyte payload (the transport accepts up to the
// 16 MiB Cursor message bound), so the serialized size is projected — object overhead
// plus raw payload length — BEFORE any encoded copy exists. Escapes only inflate the
// exact figure, making the raw length a safe lower bound for the overflow decision.
const GUARD_EVENT_OVERHEAD_BYTES = 64;
const projectedGuardEventBytes = (event: AdapterEvent): number =>
GUARD_EVENT_OVERHEAD_BYTES
+ (event.type === "text_delta"
? Buffer.byteLength(event.text, "utf8")
: event.type === "thinking_delta"
? Buffer.byteLength(event.thinking, "utf8")
: 0);
const holdGuardEvent = (event: AdapterEvent) => {
if (guardHeldBytes + projectedGuardEventBytes(event) > CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES) {
// Too large to retain even unescaped: settle the sniffers, release what was held,
// and pass this event through without ever encoding it.
echoSniffer?.finish();
routingCommentarySniffer?.finish();
releaseGuardHeld();
if (event.type !== "heartbeat") emittedOutput = true;
emitTextObserved(event);
return false;
}
guardHeld.push(event);
// Count the complete retained representation, including per-event overhead, so an
// upstream cannot evade the cap with empty or non-text reasoning frames.
guardHeldBytes += guardEncoder.encode(JSON.stringify(event)).byteLength;
if (guardHeldBytes <= CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES) return true;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
echoSniffer?.finish();
routingCommentarySniffer?.finish();
releaseGuardHeld();
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
return false;
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
};
// Each sniffer settles from a bounded leading window (40 B / 512 B respectively), so
// feeding an oversized delta whole would retain megabytes it never inspects. The
// bounded prefix still covers every decision path — including marker prefixes and
// routing claims — while the tail falls through to the aggregate cap.
const ECHO_SNIFF_FEED_MAX_CHARS = 512;
const ROUTING_SNIFF_FEED_MAX_CHARS = 2048;
const boundedSniffText = (text: string, maxChars: number): string =>
text.length > maxChars ? text.slice(0, maxChars) : text;
const guardsSettled = () =>
(!echoSniffer || echoSniffer.settled)
&& (!routingCommentarySniffer || routingCommentarySniffer.settled);
Expand Down Expand Up @@ -368,27 +413,30 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
}
if (!guardsSettled()) {
if (event.type === "text_delta") {
guardHeld.push(event);
// Classify the delta before the aggregate-cap check: an oversized first
// delta must still pass the armed sniffers (echo/hallucination detection is
// prefix-based), so the cap cannot disarm them before they see the text.
if (echoSniffer && !echoSniffer.settled) {
const decision = echoSniffer.feed(event.text);
const decision = echoSniffer.feed(boundedSniffText(event.text, ECHO_SNIFF_FEED_MAX_CHARS));
if (decision.kind === "echo") {
guardHeld = [];
throw new CursorToolResultEchoError(decision.marker);
}
}
if (routingCommentarySniffer && !routingCommentarySniffer.settled) {
const decision = routingCommentarySniffer.feed(event.text);
const decision = routingCommentarySniffer.feed(boundedSniffText(event.text, ROUTING_SNIFF_FEED_MAX_CHARS));
if (decision.kind === "hallucination") {
guardHeld = [];
throw new CursorRoutingCommentaryError();
}
}
if (!holdGuardEvent(event)) continue;
if (guardsSettled()) releaseGuardHeld();
continue;
} else if (event.type === "thinking_delta" || event.type === "heartbeat") {
// Reasoning before first text stays ordered; liveness still passes through.
if (event.type === "thinking_delta") {
guardHeld.push(event);
holdGuardEvent(event);
continue;
}
} else {
Expand Down
10 changes: 7 additions & 3 deletions src/adapters/cursor/envelope-echo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const MAX_MIDSTREAM_SCAN_LENGTH = 512 * 1024;
const MAX_MIDSTREAM_FINDINGS = 8;
const MAX_ROUTING_COMMENTARY_BYTES = 512;
/** Aggregate quarantine cap: past this, flush and disarm. */
const MAX_HOLD_BYTES = 8 * 1024;
export const CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES = 8 * 1024;
const encoder = new TextEncoder();

export class CursorToolResultEchoError extends Error {
Expand Down Expand Up @@ -251,7 +251,11 @@ export class CursorEnvelopeEchoSniffer {
const stillPrefix = ECHO_MARKERS.some(marker =>
probe.length < marker.length && marker.startsWith(probe),
);
if (stillPrefix && this.byteCount <= MAX_SNIFF_BYTES && this.buffered.length < MAX_HOLD_BYTES) {
if (
stillPrefix
&& this.byteCount <= MAX_SNIFF_BYTES
&& this.buffered.length < CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES
) {
return { kind: "hold" };
}
this.done = true;
Expand Down Expand Up @@ -316,7 +320,7 @@ export class CursorRoutingCommentarySniffer {
&& lineBreakCount < 2;
if (
this.byteCount < MAX_ROUTING_COMMENTARY_BYTES
&& this.buffered.length < MAX_HOLD_BYTES
&& this.buffered.length < CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES
&& (lineBreakCount === 0 || pendingFailureClaim)
&& (hasRoutingHint || this.byteCount < 64)
) {
Expand Down
9 changes: 9 additions & 0 deletions structure/providers/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,15 @@ Translated Chat request construction uses the [inline-image budget](../transport

## Mid-stream envelope echo

Held quarantine output is bounded by the aggregate `CURSOR_OUTPUT_GUARD_MAX_HOLD_BYTES` (8 KiB)
budget in `src/adapters/cursor.ts`. Text deltas are fed to the armed echo and
routing-commentary sniffers BEFORE the cap check, so a single oversized first delta cannot
disarm the guards without being classified; each sniffer reads only the bounded leading window
its decision needs. Retained bytes are projected from payload length before any serialized
copy exists, so a multi-megabyte frame cannot force a same-size encoded allocation. An event
that cannot fit the remaining budget settles both sniffers, releases the held events, and is
emitted directly.

The prefix sniffer only watches the opening bytes of a turn. An external model that writes real
prose first and then pastes a replayed `[Tool Result]` envelope defeats it, so that text reaches
the client and is stored as assistant output. `CursorMidstreamEchoObserver` records those
Expand Down
127 changes: 127 additions & 0 deletions tests/providers/cursor/cursor-envelope-echo-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,133 @@ describe("cursor external output quarantine + corrective retry (devlog 260826 ga
expect(text).toBe("[note] leading bracket but not an envelope");
});

test("reasoning-only quarantine is capped and disarms before unbounded retention", async () => {
let attempt = 0;
const factory = () => ({
async *run() {
attempt += 1;
for (let i = 0; i < 100; i += 1) {
yield { type: "thinking", thinking: "x".repeat(128) } satisfies CursorServerMessage;
}
// Once the aggregate hold cap flushes, later marker-like text is ordinary output rather
// than evidence for a retry whose preceding reasoning has already reached the client.
yield { type: "text", text: ECHO_TEXT } satisfies CursorServerMessage;
yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage;
},
writeClient() {},
});
const adapter = createCursorAdapter(
{ ...provider, apiKey: "cursor-token" },
{ createTransport: factory as never },
);
const events: AdapterEvent[] = [];
await adapter.runTurn?.(
toolResultBody("cursor/kimi-k3"),
{ headers: new Headers() },
event => events.push(event),
);

expect(attempt).toBe(1);
expect(events.filter(event => event.type === "thinking_delta")).toHaveLength(100);
expect(events.filter(event => event.type === "text_delta")).not.toHaveLength(0);
});

test("an oversized first text delta is still classified by the echo sniffer", async () => {
// One text delta larger than the aggregate hold cap whose leading bytes are the
// echoed envelope marker.
let attempt = 0;
const runRequests: CursorRunRequest[] = [];
const oversizedFactory = () => ({
async *run(request: CursorRunRequest) {
runRequests.push(request);
attempt += 1;
if (attempt === 1) {
yield { type: "text", text: ECHO_TEXT + "x".repeat(32 * 1024) } satisfies CursorServerMessage;
yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage;
return;
}
yield { type: "text", text: "STATE A17" } satisfies CursorServerMessage;
yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage;
},
writeClient() {},
});
const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: oversizedFactory as never });
const events: AdapterEvent[] = [];
await adapter.runTurn?.(toolResultBody("cursor/kimi-k3"), { headers: new Headers() }, event => events.push(event));
expect(attempt).toBe(2);
const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("");
expect(text).toBe("STATE A17");
});

test("an oversized first text delta is still classified by the routing sniffer", async () => {
let attempt = 0;
const factory = () => ({
async *run() {
attempt += 1;
if (attempt === 1) {
// Routing claim padded past the 8 KiB aggregate cap in a single delta.
yield {
type: "text",
text: "네이티브 셸은 차단됐으니 exec_command 경로로 읽겠습니다. " + "x".repeat(32 * 1024),
} satisfies CursorServerMessage;
yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage;
return;
}
yield { type: "text", text: "READ_OK" } satisfies CursorServerMessage;
yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage;
},
writeClient() {},
});
const body = {
modelId: "cursor/kimi-k3-1m",
context: {
messages: [{ role: "user", content: "Read the file and report its first line.", timestamp: 1 }],
tools: [{
name: "exec",
description: "Run JavaScript code to orchestrate nested tool calls.",
parameters: {},
freeform: true,
}],
},
stream: false,
options: {},
_cursorConversationId: "cursor_routing_oversized",
_cursorIdentityScope: "acct-routing-commentary",
} as OcxParsedRequest;
const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: factory as never });
const events: AdapterEvent[] = [];
await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event));
expect(attempt).toBe(2);
const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("");
expect(text).toBe("READ_OK");
});

test("a single reasoning frame larger than the cap flushes without unbounded retention", async () => {
let attempt = 0;
const bigThinking = "y".repeat(64 * 1024);
const factory = () => ({
async *run() {
attempt += 1;
yield { type: "thinking", thinking: bigThinking } satisfies CursorServerMessage;
yield { type: "text", text: "post-thought answer" } satisfies CursorServerMessage;
yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage;
},
writeClient() {},
});
const adapter = createCursorAdapter({ ...provider, apiKey: "cursor-token" }, { createTransport: factory as never });
const events: AdapterEvent[] = [];
await adapter.runTurn?.(
toolResultBody("cursor/kimi-k3"),
{ headers: new Headers() },
event => events.push(event),
);
expect(attempt).toBe(1);
const thinking = events.filter(e => e.type === "thinking_delta").map(e => (e as { thinking: string }).thinking).join("");
expect(thinking).toBe(bigThinking);
const text = events.filter(e => e.type === "text_delta").map(e => (e as { text: string }).text).join("");
expect(text).toBe("post-thought answer");
});

test("plain user turns (no trailing toolResult) never arm the sniffer", async () => {
let attempt = 0;
const factory = () => ({
Expand Down
Loading