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
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;
echoSniffer?.finish();
routingCommentarySniffer?.finish();
releaseGuardHeld();
return false;
};
// Bound each feed before a sniffer copies or encodes it. Their normal 40 B / 512 B
// hold thresholds are checked after classification, so one large frame previously
// let a late match inspect an arbitrary tail. Only these leading UTF-16 prefixes
// now participate in corrective retry; later text remains ordinary output.
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
76 changes: 51 additions & 25 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 @@ -130,16 +130,19 @@ export class CursorMidstreamEchoObserver {
private totalLength = 0;
private disarmed = false;
private lineDisarmed = false;
private corruptionWatch: { finding: MidstreamEchoFinding; remaining: number; window: string } | undefined;
private readonly corruptionWatches: Array<{
finding: MidstreamEchoFinding;
remaining: number;
window: string;
}> = [];
private readonly recorded: MidstreamEchoFinding[] = [];

feed(textDelta: string): void {
if (this.disarmed && !this.corruptionWatch) return;
if (this.disarmed && this.corruptionWatches.length === 0) return;
let index = 0;
while (index < textDelta.length) {
const newline = textDelta.indexOf("\n", index);
const segment = newline === -1 ? textDelta.slice(index) : textDelta.slice(index, newline);
if (this.corruptionWatch) this.watchCorruption(segment + (newline === -1 ? "" : "\n"));
if (!this.disarmed && !this.lineDisarmed && segment.length > 0) {
this.lineBuffer += segment;
if (this.lineBuffer.length > MAX_MIDSTREAM_LINE_INDENT + 32) {
Expand All @@ -149,6 +152,11 @@ export class CursorMidstreamEchoObserver {
}
this.checkLine();
}
// Recognize the next marker before charging its line to a corruption window.
// A call-id on the marker's own line belongs to the new finding as well.
if (this.corruptionWatches.length > 0) {
this.watchCorruption(segment + (newline === -1 ? "" : "\n"));
}
if (newline === -1) break;
this.lineBuffer = "";
this.lineDisarmed = false;
Expand All @@ -160,9 +168,7 @@ export class CursorMidstreamEchoObserver {
}

findings(): readonly MidstreamEchoFinding[] {
if (this.corruptionWatch) {
this.settleCorruption();
}
while (this.corruptionWatches.length > 0) this.settleCorruption(0);
return this.recorded;
}

Expand All @@ -187,12 +193,24 @@ export class CursorMidstreamEchoObserver {
this.lineDisarmed = true;
return;
}
const finding: MidstreamEchoFinding = {
marker,
offset: this.lineStartOffset,
callIdCorrupt: false,
};
this.corruptionWatch = { finding, remaining: MIDSTREAM_CORRUPTION_WINDOW, window: "" };
// A new marker ends the previous marker's corruption window: the text
// between two markers belongs to the earlier finding only. Without this,
// every open watch consumed the same following text, so one corrupt
// call-id after a second marker also marked the first, clean finding
// corrupt (clean-then-corrupt cross-contamination).
while (this.corruptionWatches.length > 0) this.settleCorruption(0);
if (this.recorded.length + this.corruptionWatches.length < MAX_MIDSTREAM_FINDINGS) {
const finding: MidstreamEchoFinding = {
marker,
offset: this.lineStartOffset,
callIdCorrupt: false,
};
this.corruptionWatches.push({
finding,
remaining: MIDSTREAM_CORRUPTION_WINDOW,
window: "",
});
}
this.lineDisarmed = true;
return;
}
Expand All @@ -203,24 +221,28 @@ export class CursorMidstreamEchoObserver {
}

private watchCorruption(text: string): void {
const watch = this.corruptionWatch;
if (!watch) return;
const take = Math.min(watch.remaining, text.length);
watch.window += text.slice(0, take);
watch.remaining -= take;
if (watch.remaining <= 0) this.settleCorruption();
for (const watch of this.corruptionWatches) {
const take = Math.min(watch.remaining, text.length);
watch.window += text.slice(0, take);
watch.remaining -= take;
}
let index = 0;
while (index < this.corruptionWatches.length) {
if (this.corruptionWatches[index]!.remaining <= 0) this.settleCorruption(index);
else index += 1;
}
}

private settleCorruption(): void {
const watch = this.corruptionWatch;
private settleCorruption(index: number): void {
const watch = this.corruptionWatches[index];
if (!watch) return;
const window = watch.window;
watch.finding.callIdCorrupt =
/fc_[0-9a-f]+[ \t]+mar-/.test(window)
|| /call_id: \S+[ \t]+\S+_0\b/.test(window);
if (this.recorded.length < MAX_MIDSTREAM_FINDINGS) this.recorded.push(watch.finding);
this.recorded.push(watch.finding);
// Window text is discarded here; only booleans/offsets survive.
this.corruptionWatch = undefined;
this.corruptionWatches.splice(index, 1);
}
}

Expand Down Expand Up @@ -251,7 +273,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 +342,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
52 changes: 39 additions & 13 deletions src/adapters/cursor/protobuf-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ function rootPromptMessages(
const replayRuns = new Map<RootBlobCandidate["role"], {
text: string;
entry: RootBlobCandidate;
entryIndex: number;
length: number;
}>();
const toolCallCounts = new Map<string, number>();
Expand All @@ -337,13 +338,13 @@ function rootPromptMessages(
// half, so losing it re-primes the self-reinforcing loop the breaker exists to end.
{ ...opts, text: marked, messageIndex: previous.entry.messageIndex ?? opts.messageIndex },
);
entries[entries.indexOf(previous.entry)] = replacement;
replayRuns.set(role, { text: normalized, entry: replacement, length: runLength });
entries[previous.entryIndex] = replacement;
replayRuns.set(role, { text: normalized, entry: replacement, entryIndex: previous.entryIndex, length: runLength });
return;
}
const entry = rootBlobCandidate(payload, role, opts);
entries.push(entry);
replayRuns.set(role, { text: normalized, entry, length: 1 });
replayRuns.set(role, { text: normalized, entry, entryIndex: entries.length - 1, length: 1 });
};

for (let i = 0; i < messages.length; i++) {
Expand Down Expand Up @@ -949,11 +950,26 @@ function serializeToolCallArguments(args: Record<string, unknown>): string | und

/** Truncate to a byte budget without splitting a UTF-8 sequence. */
function truncateUtf8(text: string, maxBytes: number): string {
const encoded = encoder.encode(text);
if (encoded.byteLength <= maxBytes) return text;
let end = Math.max(0, maxBytes);
while (end > 0 && (encoded[end]! & 0xc0) === 0x80) end -= 1;
return decoder.decode(encoded.subarray(0, end));
const encoded = new Uint8Array(Math.max(0, maxBytes));
const { read, written } = encoder.encodeInto(text, encoded);
return read === text.length ? text : decoder.decode(encoded.subarray(0, written));
}

/** Return the UTF-8 length only when it fits the bound, without allocating an input-sized buffer. */
function boundedUtf8ByteLength(text: string, maxBytes: number): number | undefined {
let bytes = 0;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
if (code < 0x80) bytes += 1;
else if (code < 0x800) bytes += 2;
else if (code >= 0xd800 && code <= 0xdbff && i + 1 < text.length
&& text.charCodeAt(i + 1) >= 0xdc00 && text.charCodeAt(i + 1) <= 0xdfff) {
bytes += 4;
i += 1;
} else bytes += 3;
if (bytes > maxBytes) return undefined;
}
return bytes;
}

/**
Expand All @@ -966,10 +982,8 @@ function truncateUtf8(text: string, maxBytes: number): string {
* exists to prevent. A bounded prefix still identifies the call (tool name plus the head of its
* arguments) while leaving the output room to survive.
*/
function toolCallArgumentsText(args: Record<string, unknown>): string {
const serialized = serializeToolCallArguments(args);
if (serialized === undefined) return "[unserializable arguments]";
if (encoder.encode(serialized).byteLength <= CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT) return serialized;
function serializedToolCallArgumentsText(serialized: string): string {
if (boundedUtf8ByteLength(serialized, CURSOR_INVOCATION_ARGUMENTS_BYTE_LIMIT) !== undefined) return serialized;
// The budget is the size of the RENDERED line, so the marker has to come out of it rather than be
// added on top: otherwise every truncated invocation exceeds the declared limit by the marker.
const marker = "…[arguments truncated]";
Expand All @@ -978,6 +992,11 @@ function toolCallArgumentsText(args: Record<string, unknown>): string {
return `${truncateUtf8(serialized, keep)}${marker}`;
}

function toolCallArgumentsText(args: Record<string, unknown>): string {
const serialized = serializeToolCallArguments(args);
return serialized === undefined ? "[unserializable arguments]" : serializedToolCallArgumentsText(serialized);
}

/**
* The invocation that produced a replayed tool result, rendered as ONE descriptive line inside the
* result envelope.
Expand Down Expand Up @@ -1052,8 +1071,15 @@ function restoreClippedInvocationArguments(
if (!call) continue;
const full = serializeToolCallArguments(call.arguments);
if (full === undefined) continue;
const clipped = toolCallArgumentsText(call.arguments);
const clipped = serializedToolCallArgumentsText(full);
if (clipped === full) continue;
// The replacement must add at least the raw UTF-8 argument-byte delta. Reject an impossible
// restoration with a bounded scan before building the widened string, JSON, and byte array.
const clippedBytes = encoder.encode(clipped).byteLength;
// boundedUtf8ByteLength already gives up past clippedBytes + spare, so a returned number
// always fits; a second size comparison here can never fire.
const fullBytes = boundedUtf8ByteLength(full, clippedBytes + spare);
if (fullBytes === undefined) continue;
const name = namespacedToolName(call.namespace, call.name);
// Anchored on the preceding newline. `toolResultToText` always emits the invocation after the
// `[tool_result]`, `call_id:` and `name:` lines, so the real line is never first — and
Expand Down
25 changes: 25 additions & 0 deletions structure/providers/cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ a small replay would otherwise clip a completed call's arguments with nearly the
unused. After every pruning and truncation decision is final, a second pass re-widens clipped
invocation lines out of the leftover aggregate bytes only: newest tool result first, skipping a root
whose own output was already elided, and never dropping, shrinking or reordering a retained root.
Before materializing a widened root, the pass uses a bounded UTF-8 scan to reject arguments whose
raw byte growth alone cannot fit the spare budget, and reuses its single argument serialization.
The elision skip is load bearing, reached through initiator recovery rather than through truncation
alone: a truncated root undershoots its own budget by far less than a restoration costs, but after
the equal-share pass elides a trailing run, recovery drops an elided sibling to fit the user turn and
Expand Down Expand Up @@ -192,6 +194,29 @@ Translated Chat request construction uses the [inline-image budget](../transport

## Mid-stream envelope echo

External root replay replaces duplicate runs at their recorded entry index, preserving the
original message position without rescanning the accumulated roots. Construction still visits
the complete supplied history before the existing count and byte admission rules; it does not
cut a raw-message suffix that could lose the initiating user instruction or checkpoint offsets.

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.
Each adapter feed is limited to 512 UTF-16 code units for envelope detection and 2,048 for
routing-commentary detection. Matches beyond that frame prefix intentionally do not trigger a
corrective retry; the complete text still reaches the client and the diagnostic midstream
observer. These feed limits bound temporary copies and do not promise frame-independent parsing.

Adjacent midstream markers retain separate findings, capped at eight. A new marker closes the
previous corruption window before consuming its line, including a call-id on the marker's own
line. Only marker identities, offsets and corruption booleans survive; held reasoning is released
in order before terminal errors, preserving upstream error visibility.

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
Loading
Loading