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
3 changes: 2 additions & 1 deletion src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown> = { 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
Expand Down
56 changes: 56 additions & 0 deletions src/responses/repetition-breaker.ts
Original file line number Diff line number Diff line change
@@ -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" : ""}`;
}
99 changes: 99 additions & 0 deletions tests/adapters/adapter-usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading