-
Notifications
You must be signed in to change notification settings - Fork 909
feat(cursor): repetition breaker for external-model replay priming #2667
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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.` }], | ||
|
Comment on lines
+302
to
+305
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because Useful? React with 👍 / 👎. |
||
| }, "user", {})); | ||
|
Comment on lines
+303
to
+306
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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- AGENTS.md reference: src/AGENTS.md:L19-L19 Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| let selected = entries; | ||
| let historyMessageStart = 0; | ||
|
|
||
| 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); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an external-model tool loop has the normal shape assistant
A→ tool resultR→ assistantA→ tool resultR, each entry overwriteslastReplayText, so this comparison is alwaysAversusRorRversusA. Consequently, neither value is collapsed andmaxRunLengthnever 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 👍 / 👎.