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
45 changes: 35 additions & 10 deletions src/adapters/cursor/protobuf-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,11 +949,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 +981,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 +991,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 +1070,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
2 changes: 2 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
30 changes: 30 additions & 0 deletions tests/providers/cursor/cursor-tool-result-invocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,36 @@ describe("cursor spare envelope budget restores clipped invocation arguments", (
expect(line).toContain(JSON.stringify(args));
});

test("an impossible restoration serializes its arguments only once in the refund pass", () => {
let serializations = 0;
const args = {
toJSON() {
serializations++;
return { contents: "A".repeat(CURSOR_EXTERNAL_ROOT_BYTE_LIMIT + 1) };
},
};
// The refund pass must not allocate a full-size byte buffer while deciding whether the
// restoration fits. encode() calls with a string longer than the envelope mean someone
// reintroduced the unbounded fullBytes measurement this change removed.
let oversizedEncodes = 0;
const originalEncode = TextEncoder.prototype.encode;
TextEncoder.prototype.encode = function (input?: string) {
if (typeof input === "string" && input.length > CURSOR_EXTERNAL_ROOT_BYTE_LIMIT) oversizedEncodes++;
return originalEncode.call(this, input);
};
let line: string | undefined;
try {
line = invokedLine(resultRoot(encode(writeFileHistory(args), "grok-4.6-high")));
} finally {
TextEncoder.prototype.encode = originalEncode;
}
expect(line).toEndWith("…[arguments truncated]");
// Indexing and initial rendering account for three calls; the refund pass adds exactly one and
// must reuse that serialization instead of calling the rendering helper for a fifth copy.
expect(serializations).toBe(4);
expect(oversizedEncodes).toBe(0);
});

// The refund pass must be a no-op below the cap: a line that was never clipped has nothing to
// restore, and rewriting it would only risk drift from the admission-time rendering.
test("an under-cap argument is unchanged", () => {
Expand Down
Loading