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
42 changes: 42 additions & 0 deletions devlog/_plan/260826_cursor_responses_gap/120_repetition_breaker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 120 — Repetition breaker + final stack (r1, gap-9)

## Defect

User screenshot (kimi-k3 app session): byte-identical commentary "원격
ocx 상태를 다시 확인합니다" + same ssh probe emitted 6+ consecutive
times. Same class as S2a's 180x tool-call loop and the 차단/전환 echo:
external full-replay presents N identical rounds as N identical lines,
priming line N+1.

## Fix (gap-9, PR #2667)

protobuf-request.ts external replay assembly: consecutive duplicate
assistant/tool-result entries collapse to one entry +
"[note: this exact output was produced N times in a row]"; runs >=3 add
one imperative context note ("Repeating it again is a failure. Take a
DIFFERENT action now..."). User messages reset runs; native models and
structured pairing untouched. 5 regression tests; 211-test suite green.

## Live proof

Probe: history primed with 5 identical assistant rounds -> model reply:
"이전에 같은 상태 확인만 반복했으니, 이번에는 코드와 원격 OCX 설정을
직접 찾아서 최신 버전으로 올립니다." — loop broken on first response.
(/tmp/ocx-wire/rep-out.json; service pid 54225 on gap-9.)

## Final stack (gap-1..gap-9)

| PR | Branch | Fix |
|---|---|---|
| #2650 | cursor-gap-1 | call_id single-line codec + response.in_progress |
| #2651 | cursor-gap-2 | bare-caller default catalog suppression (token floor) |
| #2652 | cursor-gap-3 | tool-suspended checkpoint commit (external) |
| #2653 | cursor-gap-4 | dead-model catalog quarantine |
| #2654 | cursor-gap-5 | ultra toggle kimi-k3-1m + Max Mode wire flag |
| #2656 | cursor-gap-6 | blob integrity diagnostic + G2 capture procedure |
| #2662 | cursor-gap-7 | empty exec explanation + code-mode native ban |
| #2665 | cursor-gap-8 | silent-redirect denials + commentary/shell-write bans |
| #2667 | cursor-gap-9 | repetition breaker (this) |

Merge order: #2650 first; each child retargets to dev after its parent
lands (enforce-target skips stacked children).
57 changes: 50 additions & 7 deletions src/adapters/cursor/protobuf-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,42 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
const echoToolResultInRoot = cursorNeedsExternalToolContinuation(request.modelId);
const lastRawIsToolResult = messages.at(-1)?.role === "toolResult";
const activeUserIndex = lastRawIsToolResult ? -1 : lastActionIndex(messages);
// Repetition breaker (devlog 260826 gap-9): external full-replay flattens history to text,
// so N identical assistant/tool-result rounds replay as N identical lines and PRIME the model
// to emit the same line again (self-reinforcing loop: S2a 180x, identical-probe repetition).
// Collapse consecutive duplicates into one entry + a count marker, and count collapses so a
// strategy-change note can be appended when the pattern is severe.
let lastReplayText: string | undefined;
let lastReplayEntry: RootBlobCandidate | undefined;
let collapsedRepeats = 0;
let maxRunLength = 1;
let currentRun = 1;
const pushDeduped = (
payload: { role: string; content: [{ type: "text"; text: string }] },
role: RootBlobCandidate["role"],
opts: { messageIndex: number; text?: string },
normalized: string,
): void => {
if (externalModel && lastReplayText !== undefined && normalized === lastReplayText && lastReplayEntry) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Detect interleaved repeated tool rounds

When an external-model tool loop has the normal shape assistant A → tool result R → assistant A → tool result R, each entry overwrites lastReplayText, so this comparison is always A versus R or R versus A. Consequently, neither value is collapsed and maxRunLength never reaches 3, meaning the breaker does nothing for the reported commentary-plus-probe loop; track repetition per role or compare complete rounds, and cover an interleaved assistant/tool-result transcript in the regression test.

Useful? React with 👍 / 👎.

collapsedRepeats++;
currentRun++;
if (currentRun > maxRunLength) maxRunLength = currentRun;
const marked = `${normalized}\n[note: this exact output was produced ${currentRun} times in a row]`;
const replacement = rootBlobCandidate(
{ role: payload.role, content: [{ type: "text", text: marked }] },
role,
opts,
);
entries[entries.indexOf(lastReplayEntry)] = replacement;
lastReplayEntry = replacement;
return;
}
currentRun = 1;
const entry = rootBlobCandidate(payload, role, opts);
entries.push(entry);
lastReplayText = normalized;
lastReplayEntry = entry;
};

for (let i = 0; i < messages.length; i++) {
if (i === activeUserIndex) break;
Expand All @@ -229,6 +265,9 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
// A bare string survives blob hydration but external workers reject the completed replay
// before tokenization (`usedTokens: 0`, then invalid_argument).
if (text.length > 0) {
lastReplayText = undefined;
lastReplayEntry = undefined;
currentRun = 1;
entries.push(rootBlobCandidate({
role: "user",
content: [{ type: "text", text }],
Expand All @@ -239,11 +278,12 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
// Native Composer state can preserve it through ThinkingMessage/history structures.
const text = assistantRootText(message, !externalModel).trim();
if (text.length > 0) {
entries.push(rootBlobCandidate(
pushDeduped(
{ role: "assistant", content: [{ type: "text", text }] },
"assistant",
{ messageIndex: i },
));
text,
);
}
// Assistant tool CALLS are intentionally NOT replayed as visible "[Tool Call]" text here.
} else if (message.role === "toolResult") {
Expand All @@ -255,13 +295,16 @@ function rootPromptMessages(request: CursorRunRequest, requestScope: CursorBlobR
// node_repl result is an error even when the runtime said isError=false).
const prefix = normalizedToolResult(message, contentToText(message.content)).isError ? "[Tool Error]" : "[Tool Result]";
const text = `${prefix}\n${toolResultToText(message)}`;
entries.push(rootBlobCandidate(
toolResultRootPayload(text),
"toolResult",
{ messageIndex: i, text },
));
pushDeduped(toolResultRootPayload(text), "toolResult", { messageIndex: i, text }, text);
}
}
// Severe repetition: tell the model ONCE, imperatively, to change strategy.
if (externalModel && maxRunLength >= 3) {
entries.push(rootBlobCandidate({
role: "user",
content: [{ type: "text", text: `[context note] The transcript above contains the same output repeated ${maxRunLength} times in a row. Repeating it again is a failure. Take a DIFFERENT action now, or state plainly what is blocking progress.` }],
Comment on lines +302 to +305

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 Stop carrying a resolved repetition warning forward

Because maxRunLength records the maximum over the entire raw history and is never reset when a user message starts a new turn, one old three-output run causes this imperative strategy-change note to be appended to every subsequent request in the conversation. After the user changes tasks and the model makes unrelated progress—and even after replay pruning removes the repeated entries—the prompt still orders the model to take a different action; base the warning on the trailing/current run that actually survives selection rather than any historical run.

Useful? React with 👍 / 👎.

}, "user", {}));
Comment on lines +303 to +306

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep the active tool result at the replay tail

When any repetition run reached three and the current request ends in a tool result, appending this synthetic user root after that result prevents the later trailing-toolResult scan from recognizing the active block. Under the 192-blob or 512 KiB replay cap, the current result is then treated as ordinary prior history and can be dropped instead of preserved or truncated, while the continuation action still tells the model that the result is in history; insert the note before the active block or make pruning explicitly retain both.

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

Useful? React with 👍 / 👎.

}

let selected = entries;
let historyMessageStart = 0;
Expand Down
98 changes: 98 additions & 0 deletions tests/cursor-repetition-breaker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, test } from "bun:test";
import { fromBinary } from "@bufbuild/protobuf";
import { encodeCursorRunRequest } from "../src/adapters/cursor/protobuf-request";
import { handleCursorNativeKv } from "../src/adapters/cursor/native-exec";
import { create } from "@bufbuild/protobuf";
import {
AgentClientMessageSchema,
GetBlobArgsSchema,
KvServerMessageSchema,
} from "../src/adapters/cursor/gen/agent_pb";
import type { OcxMessage } from "../src/types";

function blobData(blobId: Uint8Array): Uint8Array {
const reply = fromBinary(AgentClientMessageSchema, handleCursorNativeKv(create(KvServerMessageSchema, {
id: 1,
message: { case: "getBlobArgs", value: create(GetBlobArgsSchema, { blobId }) },
})));
if (reply.message.case !== "kvClientMessage" || reply.message.value.message.case !== "getBlobResult") {
throw new Error("expected getBlobResult");
}
return reply.message.value.message.value.blobData!;
}

function rootTexts(bytes: Uint8Array): string[] {
const msg = fromBinary(AgentClientMessageSchema, bytes);
const run = msg.message.case === "runRequest" ? msg.message.value : undefined;
return (run?.conversationState?.rootPromptMessagesJson ?? []).map(blobId => {
const parsed = JSON.parse(new TextDecoder().decode(blobData(blobId))) as { content?: [{ text?: string }] };
return parsed.content?.[0]?.text ?? "";
});
}

const REPEAT = "원격 ocx 상태를 다시 확인합니다.";

function repeatedHistory(times: number): OcxMessage[] {
const messages: OcxMessage[] = [{ role: "user", content: "원격 ocx를 최신 버전으로 업데이트해봐", timestamp: 1 }];
for (let i = 0; i < times; i++) {
messages.push({ role: "assistant", content: REPEAT, timestamp: 2 + i } as OcxMessage);
}
messages.push({ role: "user", content: "계속", timestamp: 100 });
return messages;
}

function encode(messages: OcxMessage[], modelId = "grok-4.6-high") {
return encodeCursorRunRequest({
modelId,
conversationId: "c_rep",
system: [],
messages: [],
rawMessages: messages,
});
}

describe("cursor external-replay repetition breaker (devlog 260826 gap-9)", () => {
test("consecutive identical assistant entries collapse into one marked entry", () => {
const texts = rootTexts(encode(repeatedHistory(5)));
const repeats = texts.filter(text => text.startsWith(REPEAT));
expect(repeats).toHaveLength(1);
expect(repeats[0]).toContain("5 times in a row");
});

test("severe repetition appends exactly one strategy-change note", () => {
const texts = rootTexts(encode(repeatedHistory(4)));
const notes = texts.filter(text => text.includes("Take a DIFFERENT action now"));
expect(notes).toHaveLength(1);
});

test("two repeats collapse but do not trigger the note", () => {
const texts = rootTexts(encode(repeatedHistory(2)));
expect(texts.filter(text => text.includes("2 times in a row"))).toHaveLength(1);
expect(texts.filter(text => text.includes("Take a DIFFERENT action now"))).toHaveLength(0);
});

test("distinct assistant entries stay untouched", () => {
const messages: OcxMessage[] = [
{ role: "user", content: "hi", timestamp: 1 },
{ role: "assistant", content: "step one done", timestamp: 2 },
{ role: "assistant", content: "step two done", timestamp: 3 },
{ role: "user", content: "continue", timestamp: 4 },
] as OcxMessage[];
const texts = rootTexts(encode(messages));
expect(texts).toContain("step one done");
expect(texts).toContain("step two done");
expect(texts.some(text => text.includes("times in a row"))).toBe(false);
});

test("duplicates separated by a user message do not collapse", () => {
const messages: OcxMessage[] = [
{ role: "user", content: "go", timestamp: 1 },
{ role: "assistant", content: REPEAT, timestamp: 2 },
{ role: "user", content: "again", timestamp: 3 },
{ role: "assistant", content: REPEAT, timestamp: 4 },
{ role: "user", content: "final", timestamp: 5 },
] as OcxMessage[];
const texts = rootTexts(encode(messages));
expect(texts.filter(text => text === REPEAT)).toHaveLength(2);
});
});
Loading