From 35667bb7f23c8146630540036eace2f90c37f28b Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 26 Aug 2026 14:23:27 +0900 Subject: [PATCH 1/2] feat(cursor): repetition breaker for external-model replay priming --- src/adapters/cursor/protobuf-request.ts | 57 ++++++++++++-- tests/cursor-repetition-breaker.test.ts | 98 +++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 tests/cursor-repetition-breaker.test.ts diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 5b4e07b426..28eb363821 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -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) { + 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; @@ -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 }], @@ -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") { @@ -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.` }], + }, "user", {})); + } let selected = entries; let historyMessageStart = 0; diff --git a/tests/cursor-repetition-breaker.test.ts b/tests/cursor-repetition-breaker.test.ts new file mode 100644 index 0000000000..63708f7a32 --- /dev/null +++ b/tests/cursor-repetition-breaker.test.ts @@ -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); + }); +}); From d9d84b3921050d662d85e651ab9e985c8fc5aea0 Mon Sep 17 00:00:00 2001 From: jun Date: Wed, 26 Aug 2026 14:26:43 +0900 Subject: [PATCH 2/2] devlog(260826_cursor_responses_gap): repetition breaker + final stack table --- .../120_repetition_breaker.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 devlog/_plan/260826_cursor_responses_gap/120_repetition_breaker.md diff --git a/devlog/_plan/260826_cursor_responses_gap/120_repetition_breaker.md b/devlog/_plan/260826_cursor_responses_gap/120_repetition_breaker.md new file mode 100644 index 0000000000..1f72a6675d --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/120_repetition_breaker.md @@ -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).