diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 51902ee0b8..7a85d3f151 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -42,6 +42,7 @@ import { TRANSLATOR_MAX_SSE_EVENT_BYTES, type TranslatorBudget, } from "../lib/translator-budget"; +import { collapseRepeatedOutput } from "../responses/repetition-breaker"; // Providers may opt into stripping one trailing "[...]" group from the wire model id. // Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211; @@ -830,7 +831,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon const thinkingParts = aMsg.content.filter(p => p.type === "thinking") as OcxThinkingContent[]; const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[]; const chatMsg: Record = { role: "assistant" }; - if (textParts.length > 0) chatMsg.content = textParts.map(p => p.text).join(""); + if (textParts.length > 0) chatMsg.content = collapseRepeatedOutput(textParts.map(p => p.text).join("")); let reasoningContent = thinkingParts.map(p => p.thinking).join(""); if ( reasoningContent.length === 0 diff --git a/src/responses/repetition-breaker.ts b/src/responses/repetition-breaker.ts new file mode 100644 index 0000000000..7b898d3e4a --- /dev/null +++ b/src/responses/repetition-breaker.ts @@ -0,0 +1,56 @@ +/** + * Collapse repeated assistant output before it is replayed to an upstream model. + * + * A degenerate generation may contain the same line or short paragraph hundreds of + * times. Sending that history back verbatim primes the next generation to continue + * the run. The client still receives the original response; this only changes the + * history used for a later request. + */ +const MIN_REPETITIONS = 3; +const MAX_CYCLE_LINES = 20_000; +const MAX_CYCLE_PERIOD = 64; + +const marker = (count: number): string => `[ocx: repeated ${count} times in source output]`; + +function collapseConsecutiveLines(lines: string[]): string[] { + const out: string[] = []; + for (let start = 0; start < lines.length;) { + const line = lines[start]!; + let end = start + 1; + while (end < lines.length && lines[end] === line) end += 1; + const count = end - start; + if (line.trim().length > 0 && count >= MIN_REPETITIONS) { + out.push(line, marker(count)); + } else { + out.push(...lines.slice(start, end)); + } + start = end; + } + return out; +} + +function collapseWholeMessageCycle(lines: string[]): string[] { + if (lines.length > MAX_CYCLE_LINES) return lines; + const maxPeriod = Math.min(MAX_CYCLE_PERIOD, Math.floor(lines.length / MIN_REPETITIONS)); + for (let period = 1; period <= maxPeriod; period += 1) { + const count = Math.floor(lines.length / period); + const block = lines.slice(0, period); + if (block.every(line => line.trim().length === 0)) continue; + const repeatedLength = count * period; + if ( + lines.slice(0, repeatedLength).every((line, index) => line === block[index % period]) + && lines.slice(repeatedLength).every((line, index) => line === block[index]) + ) { + return [...block, marker(count), ...lines.slice(repeatedLength)]; + } + } + return lines; +} + +export function collapseRepeatedOutput(text: string): string { + if (text.length === 0) return text; + const hasTerminalNewline = text.endsWith("\n"); + const lines = (hasTerminalNewline ? text.slice(0, -1) : text).split("\n"); + const collapsed = collapseConsecutiveLines(lines); + return `${collapseWholeMessageCycle(collapsed).join("\n")}${hasTerminalNewline ? "\n" : ""}`; +} diff --git a/tests/adapters/adapter-usage.test.ts b/tests/adapters/adapter-usage.test.ts index 7967f2a796..945ce3d4e0 100644 --- a/tests/adapters/adapter-usage.test.ts +++ b/tests/adapters/adapter-usage.test.ts @@ -356,6 +356,105 @@ describe("usage and content retention (F2)", () => { }); describe("openai-chat tool history repair", () => { + test("collapses repeated assistant output before replaying it upstream", async () => { + const adapter = createOpenAIChatAdapter(provider); + const repeated = Array.from({ length: 1451 }, () => "Checking the adapter state.").join("\n"); + const request = await adapter.buildRequest({ + modelId: "deepseek-v4", + context: { + messages: [ + { role: "user", content: "start", timestamp: 0 }, + { role: "assistant", content: [{ type: "text", text: repeated }], model: "deepseek-v4", timestamp: 1 }, + { role: "user", content: "continue", timestamp: 2 }, + ], + }, + stream: true, + options: {}, + }); + const body = JSON.parse(request.body) as { messages: Array<{ content: string }> }; + + expect(body.messages[1]?.content).toBe("Checking the adapter state.\n[ocx: repeated 1451 times in source output]"); + }); + + test("collapses a repeated multi-line assistant message before replaying it upstream", async () => { + const adapter = createOpenAIChatAdapter(provider); + const paragraph = "Checking the adapter state.\nRunning the focused test."; + const request = await adapter.buildRequest({ + modelId: "deepseek-v4", + context: { + messages: [ + { role: "user", content: "start", timestamp: 0 }, + { role: "assistant", content: [{ type: "text", text: Array.from({ length: 4 }, () => paragraph).join("\n") }], model: "deepseek-v4", timestamp: 1 }, + { role: "user", content: "continue", timestamp: 2 }, + ], + }, + stream: true, + options: {}, + }); + const body = JSON.parse(request.body) as { messages: Array<{ content: string }> }; + + expect(body.messages[1]?.content).toBe(`${paragraph}\n[ocx: repeated 4 times in source output]`); + }); + + test("collapses a repeated multi-line assistant message with a terminal newline", async () => { + const adapter = createOpenAIChatAdapter(provider); + const repeated = "A\nB\n".repeat(3); + const request = await adapter.buildRequest({ + modelId: "deepseek-v4", + context: { + messages: [ + { role: "user", content: "start", timestamp: 0 }, + { role: "assistant", content: [{ type: "text", text: repeated }], model: "deepseek-v4", timestamp: 1 }, + { role: "user", content: "continue", timestamp: 2 }, + ], + }, + stream: true, + options: {}, + }); + const body = JSON.parse(request.body) as { messages: Array<{ content: string }> }; + + expect(body.messages[1]?.content).toBe("A\nB\n[ocx: repeated 3 times in source output]\n"); + }); + + test("collapses complete cycles while preserving a trailing partial cycle", async () => { + const adapter = createOpenAIChatAdapter(provider); + const request = await adapter.buildRequest({ + modelId: "deepseek-v4", + context: { + messages: [ + { role: "user", content: "start", timestamp: 0 }, + { role: "assistant", content: [{ type: "text", text: "A\nB\nA\nB\nA\nB\nA" }], model: "deepseek-v4", timestamp: 1 }, + { role: "user", content: "continue", timestamp: 2 }, + ], + }, + stream: true, + options: {}, + }); + const body = JSON.parse(request.body) as { messages: Array<{ content: string }> }; + + expect(body.messages[1]?.content).toBe("A\nB\n[ocx: repeated 3 times in source output]\nA"); + }); + + test("preserves ordinary assistant output when replaying it upstream", async () => { + const adapter = createOpenAIChatAdapter(provider); + const text = "Checking the adapter state.\nChecking the adapter state."; + const request = await adapter.buildRequest({ + modelId: "deepseek-v4", + context: { + messages: [ + { role: "user", content: "start", timestamp: 0 }, + { role: "assistant", content: [{ type: "text", text }], model: "deepseek-v4", timestamp: 1 }, + { role: "user", content: "continue", timestamp: 2 }, + ], + }, + stream: true, + options: {}, + }); + const body = JSON.parse(request.body) as { messages: Array<{ content: string }> }; + + expect(body.messages[1]?.content).toBe(text); + }); + test("inserts a synthetic assistant tool_call before orphan tool results", async () => { const adapter = createOpenAIChatAdapter(provider); const request = await adapter.buildRequest({