fix(ai): stop runaway repeated responses

This commit is contained in:
Patrick Stark
2026-08-15 11:38:21 -06:00
parent c246f09200
commit 53378a402d
4 changed files with 322 additions and 166 deletions
+59 -57
View File
@@ -1022,45 +1022,47 @@ function streamDispatch<TApi extends Api>(
}
}
/** Thinking-loop re-samples spent before {@link resolveWithThinkingLoopCook} cooks. */
const THINKING_LOOP_MAX_ABORTS = 3;
/** Maximum guarded attempts for a detected thinking loop. */
const THINKING_LOOP_MAX_ATTEMPTS = 3;
const THINKING_LOOP_RETRY_BASE_DELAY_MS = 500;
const THINKING_LOOP_RETRY_MAX_DELAY_MS = 8_000;
function isRetryableThinkingLoop(message: AssistantMessage): boolean {
return (
message.stopReason === "error" &&
message.content.length === 0 &&
AIError.is(message.errorId, AIError.Flag.ThinkingLoop)
);
}
/**
* Resolve a completion, re-sampling a thinking-loop stall up to
* {@link THINKING_LOOP_MAX_ABORTS} times before letting it cook. The loop guard
* raises an empty `stopReason: "error"` stall on each guarded attempt; this
* result-path consumer re-dispatches a fresh request per stall and, once the abort
* budget is spent, runs one final pass with the guard disabled so a stubborn loop
* returns the model's raw output instead of a fatal stall. Non-stall results —
* including genuine errors — return immediately; a caller abort during backoff
* propagates so cancellation surfaces as an abort, never a stale stall result.
* Resolve a completion, re-sampling a thinking-loop stall for at most
* {@link THINKING_LOOP_MAX_ATTEMPTS} guarded attempts. The loop guard raises an
* empty `stopReason: "error"` stall; after the budget is spent that error is
* returned unchanged. Detection is never disabled as a fallback, because an
* unguarded retry can consume the remaining output budget and persist runaway
* content. Non-stall results, including genuine errors, return immediately. A
* caller abort during backoff propagates so cancellation surfaces as an abort,
* never a stale stall result.
*/
async function resolveWithThinkingLoopCook(
async function resolveWithThinkingLoopRetries(
signal: AbortSignal | undefined,
dispatch: () => AssistantMessageEventStream,
cook: () => AssistantMessageEventStream,
): Promise<AssistantMessage> {
let message = await dispatch().result();
let thinkingLoopRetry = AIError.is(message.errorId, AIError.Flag.ThinkingLoop);
for (let attempt = 0; thinkingLoopRetry && attempt < THINKING_LOOP_MAX_ABORTS - 1; attempt += 1) {
let thinkingLoopRetry = isRetryableThinkingLoop(message);
for (let attempt = 1; thinkingLoopRetry && attempt < THINKING_LOOP_MAX_ATTEMPTS; attempt += 1) {
// A caller abort surfaces as a thrown abort (never the stall, which would
// misclassify as a 502): throwIfAborted before backoff, and scheduler.wait
// rejects if the abort lands mid-delay.
signal?.throwIfAborted();
const delay = Math.min(THINKING_LOOP_RETRY_BASE_DELAY_MS * 2 ** attempt, THINKING_LOOP_RETRY_MAX_DELAY_MS);
const delay = Math.min(THINKING_LOOP_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), THINKING_LOOP_RETRY_MAX_DELAY_MS);
await scheduler.wait(delay, { signal });
message = await dispatch().result();
thinkingLoopRetry =
message.stopReason === "error" &&
message.content.length === 0 &&
AIError.is(message.errorId, AIError.Flag.ThinkingLoop);
thinkingLoopRetry = isRetryableThinkingLoop(message);
}
if (!thinkingLoopRetry) return message;
signal?.throwIfAborted();
// Abort budget spent and still looping: let it cook with the guard disabled.
return cook().result();
if (thinkingLoopRetry) signal?.throwIfAborted();
return message;
}
export async function complete<TApi extends Api>(
@@ -1068,11 +1070,7 @@ export async function complete<TApi extends Api>(
context: Context,
options?: OptionsForApi<TApi>,
): Promise<AssistantMessage> {
return resolveWithThinkingLoopCook(
options?.signal,
() => stream(model, context, options),
() => stream(model, context, { ...options, loopGuard: { ...options?.loopGuard, enabled: false } }),
);
return resolveWithThinkingLoopRetries(options?.signal, () => stream(model, context, options));
}
type AuthRetryFailure = {
@@ -1578,23 +1576,27 @@ function streamSimpleRequest<TApi extends Api>(
// GitLab Duo - wraps Anthropic/OpenAI behind GitLab AI Gateway direct access tokens
if (isGitLabDuoModel(model)) {
return withProviderInFlightLimit(model, requestOptions, () =>
streamGitLabDuo(model, context, {
...requestOptions,
apiKey,
}),
return withThinkingLoopGuard(model, requestOptions, opts =>
withProviderInFlightLimit(model, opts, () =>
streamGitLabDuo(model, context, {
...opts,
apiKey,
}),
),
);
}
// GitLab Duo Workflow - IDE workflow protocol + WebSocket action bridge
if (model.api === "gitlab-duo-agent") {
// Does not route through withProviderInFlightLimit, so heal explicitly.
return healLeakedThinking(
model,
streamGitLabDuoWorkflow(model as Model<"gitlab-duo-agent">, context, {
...requestOptions,
apiKey,
}),
return withThinkingLoopGuard(model, requestOptions, opts =>
healLeakedThinking(
model,
streamGitLabDuoWorkflow(model as Model<"gitlab-duo-agent">, context, {
...opts,
apiKey,
}),
),
);
}
@@ -1606,24 +1608,28 @@ function streamSimpleRequest<TApi extends Api>(
// thinking, so clamp disabled requests to the lowest supported effort
// (mirrors the mapOptionsForApi path every other provider takes).
const kimiOptions = normalizeMandatoryReasoningOptions(model, requestOptions);
return withProviderInFlightLimit(model, kimiOptions, () =>
streamKimi(model as Model<"openai-completions">, context, {
...kimiOptions,
apiKey,
format: kimiOptions?.kimiApiFormat,
}),
return withThinkingLoopGuard(model, kimiOptions, opts =>
withProviderInFlightLimit(model, opts, () =>
streamKimi(model as Model<"openai-completions">, context, {
...opts,
apiKey,
format: opts?.kimiApiFormat,
}),
),
);
}
// Synthetic - route to dedicated handler that wraps OpenAI or Anthropic API
if (isSyntheticModel(model)) {
// Pass raw SimpleStreamOptions - streamSynthetic handles mapping internally
return withProviderInFlightLimit(model, requestOptions, () =>
streamSynthetic(model as Model<"openai-completions">, context, {
...requestOptions,
apiKey,
format: requestOptions?.syntheticApiFormat ?? "openai", // Default to OpenAI format
}),
// Pass raw SimpleStreamOptions - streamSynthetic handles mapping internally.
return withThinkingLoopGuard(model, requestOptions, opts =>
withProviderInFlightLimit(model, opts, () =>
streamSynthetic(model as Model<"openai-completions">, context, {
...opts,
apiKey,
format: opts?.syntheticApiFormat ?? "openai",
}),
),
);
}
const providerOptions = mapOptionsForApi(model, requestOptions, apiKey);
@@ -1635,11 +1641,7 @@ export async function completeSimple<TApi extends Api>(
context: Context,
options?: SimpleStreamOptions,
): Promise<AssistantMessage> {
return resolveWithThinkingLoopCook(
options?.signal,
() => streamSimple(model, context, options),
() => streamSimple(model, context, { ...options, loopGuard: { ...options?.loopGuard, enabled: false } }),
);
return resolveWithThinkingLoopRetries(options?.signal, () => streamSimple(model, context, options));
}
const MIN_OUTPUT_TOKENS = 1024;
+96 -63
View File
@@ -8,15 +8,17 @@
* or answering. The runaway is *not* byte-identical, so a cheap verbatim
* tail-repeat check alone misses it.
*
* This guard watches the streamed `thinking` deltas and, on a match, terminates
* the stream with a synthetic `error` {@link AssistantMessage} that carries
* **no observable content**. An empty-content `stopReason: "error"` message tagged
* with `AIError.Flag.ThinkingLoop` lets result consumers and `AgentSession` discard
* the runaway and re-sample instead of committing garbage transcript.
* This guard watches streamed deltas and, on a match, terminates the stream with
* a synthetic `error` {@link AssistantMessage} whose terminal content is empty.
* Deltas emitted before enough evidence accumulates may already be observable to
* a live streaming consumer; the empty terminal prevents the failed attempt from
* being committed or replayed. Tagged with `AIError.Flag.ThinkingLoop`, the
* result lets `AgentSession` discard the runaway and re-sample.
*
* Three failure shapes are detected:
* 1. **Verbatim tail repetition** — a short unit repeated back-to-back (e.g.
* "🌊 🌊 🌊 …"). Caught from a rolling 250-char tail.
* Four failure shapes are detected:
* 1. **Exact suffix cycles** — a byte-identical unit repeated back-to-back,
* including long cycles such as the observed 311-character Kiro runaway.
* This bounded detector applies to every model.
* 2. **Near-duplicate segments** — paragraphs that normalize to the same
* word-trigram fingerprint. Caught with a Jaccard window over recent
* paragraphs. Thresholds were calibrated on a real loop transcript plus
@@ -28,13 +30,16 @@
* vocabulary and name nothing concrete. Caught by a run of low-novelty,
* anchor-free segments; a segment naming a path/identifier resets the run, so
* genuine but vocabulary-repetitive work (per-file templates) is spared.
* 4. **Gemini summary-header runaway** — handled separately by
* {@link GeminiHeaderRunDetector}.
*
* Scope is narrow: guarded Gemini, DeepSeek, and Grok family streams before any tool call. Native
* thinking is checked first; assistant text can also be checked for providers
* that surface reasoning as visible prose. On a hit the failed turn is emitted as
* an empty retryable stream-stall error; result-awaiting callers (`complete`,
* `completeSimple`) re-sample it a few times and then let a stubborn loop cook
* through one unguarded pass. Disable detection with `PI_NO_THINKING_LOOP_GUARD=1`.
* Scope: exact cycles are guarded for every model; semantic heuristics remain
* limited to Gemini, DeepSeek, and Grok family streams before any tool call.
* Native thinking is checked first; assistant text can also be checked for
* providers that surface reasoning as visible prose. On a hit the failed turn is
* emitted as an empty retryable stream-stall error; result-awaiting callers
* (`complete`, `completeSimple`) re-sample at most three guarded attempts and
* then fail closed. Disable detection with `PI_NO_THINKING_LOOP_GUARD=1`.
*/
import { modelFamilyToken } from "@oh-my-pi/pi-catalog/identity";
import { logger } from "@oh-my-pi/pi-utils";
@@ -47,12 +52,17 @@ import { AssistantMessageEventStream } from "./event-stream";
* classifiers treat it as a transient (retryable) stop without bespoke rules. */
export const THINKING_LOOP_ERROR_MARKER = "Thinking loop detected";
/** Rolling tail (chars) inspected for verbatim back-to-back repetition. */
const VERBATIM_TAIL_WINDOW = 250;
/** Minimum total repeated chars before a verbatim run counts as a loop. */
const VERBATIM_MIN_REPEATED_CHARS = 180;
/** Longest unit length probed for a verbatim repeat. */
const VERBATIM_MAX_UNIT = 60;
/** Rolling tail retained for exact suffix-cycle detection. */
const EXACT_TAIL_WINDOW = 4096;
/** Longest exact cycle length considered. */
const EXACT_MAX_UNIT = 1024;
/** New characters between scans. Large deltas are scanned immediately. */
const EXACT_CHECK_STRIDE = 128;
/** Short cycles need four repeats covering at least this many characters. */
const EXACT_SHORT_MAX_UNIT = 60;
const EXACT_SHORT_MIN_REPEATED_CHARS = 180;
/** Long cycles need at least three repeats covering at least this many chars. */
const EXACT_LONG_MIN_REPEATED_CHARS = 1024;
/** Char cap for an unterminated segment; forces a flush so a wall-of-text loop
* (no blank lines / headings) still segments. */
@@ -96,11 +106,12 @@ const CONCRETE_ANCHOR =
/`[^`]+`|\b\w{2,}\.[a-zA-Z]\w{0,4}\b|[\w-]+(?:\/[\w-]+){2,}|\b\w+_\w+\b|\b[a-z]+[A-Z]\w*\b|\b[A-Z][a-z]+[A-Z]\w*\b/g;
/**
* True when `model.id` belongs to a family guarded for thinking/response loops:
* Gemini, DeepSeek, or Grok.
* True when `model.id` belongs to a family guarded by the semantic loop
* heuristics: Gemini, DeepSeek, or Grok. Exact suffix-cycle detection applies to
* every enabled model independently of this predicate.
*
* Model identity is derived only from its id; provider and compatibility metadata
* do not opt opaque aliases into the guard.
* do not opt opaque aliases into semantic detection.
*/
export function isLoopGuardedModel(model: Model<Api>, options?: StreamOptions): boolean {
if (options?.loopGuard?.enabled === false) return false;
@@ -120,8 +131,10 @@ export function isLoopGuardedModel(model: Model<Api>, options?: StreamOptions):
* is responsible for stopping after the first hit.
*/
export class ThinkingLoopDetector {
/** Rolling char tail for verbatim repeat detection. */
/** Rolling char tail for exact suffix-cycle detection. */
#tail = "";
/** Total characters received when the exact detector last scanned. */
#exactScannedAt = 0;
/** Pending thinking text not yet split into completed segments. */
#pending = "";
/** Fingerprints of the most recent substantial segments (≤ SEGMENT_WINDOW). */
@@ -138,18 +151,27 @@ export class ThinkingLoopDetector {
* path/identifier every paragraph is still caught. */
#anchorWindow: Set<string>[] = [];
constructor(private readonly semanticHeuristics = true) {}
push(delta: string): string | null {
if (!delta) return null;
// 1. Verbatim back-to-back repetition over the rolling tail.
// 1. Exact suffix cycles. Scan at a bounded cadence rather than doing
// quadratic work for every token-sized delta.
this.#tail += delta;
if (this.#tail.length > VERBATIM_TAIL_WINDOW) this.#tail = this.#tail.slice(-VERBATIM_TAIL_WINDOW);
const verbatim = detectVerbatimRepetition(this.#tail);
if (verbatim) {
const [unit, times] = verbatim;
return `repeated "${unit.trim()}" ${times}× back-to-back`;
if (this.#tail.length > EXACT_TAIL_WINDOW) this.#tail = this.#tail.slice(-EXACT_TAIL_WINDOW);
this.#exactScannedAt += delta.length;
if (this.#exactScannedAt >= EXACT_CHECK_STRIDE || delta.length >= EXACT_CHECK_STRIDE) {
this.#exactScannedAt = 0;
const exact = detectExactSuffixCycle(this.#tail);
if (exact) {
const [unit, times] = exact;
return `repeated an exact ${unit.length}-character cycle ${times}× back-to-back`;
}
}
if (!this.semanticHeuristics) return null;
// 2. Near-duplicate paragraph loop. Append, then drain completed segments.
this.#pending += delta;
while (true) {
@@ -179,7 +201,14 @@ export class ThinkingLoopDetector {
* terminator). Called when the thinking block ends so the final segment —
* which may be the one that completes a duplicate cluster — is not dropped. */
flush(): string | null {
if (!this.#pending) return null;
// A stream can end before the next cadence boundary. Force one final exact
// check even when semantic heuristics are disabled and #pending is empty.
const exact = detectExactSuffixCycle(this.#tail);
if (exact) {
const [unit, times] = exact;
return `repeated an exact ${unit.length}-character cycle ${times}× back-to-back`;
}
if (!this.semanticHeuristics || !this.#pending) return null;
let rest = this.#pending;
this.#pending = "";
while (rest.length > 0) {
@@ -349,8 +378,9 @@ export function guardThinkingLoopStream(
options?: StreamOptions,
): AssistantMessageEventStream {
const outer = new AssistantMessageEventStream();
const thinkingDetector = new ThinkingLoopDetector();
const textDetector = new ThinkingLoopDetector();
const semanticHeuristics = isLoopGuardedModel(model, options);
const thinkingDetector = new ThinkingLoopDetector(semanticHeuristics);
const textDetector = new ThinkingLoopDetector(semanticHeuristics);
const checkAssistantContent = options?.loopGuard?.checkAssistantContent !== false;
void (async () => {
@@ -415,12 +445,12 @@ export function guardThinkingLoopStream(
}
/**
* Apply the loop guard around a provider dispatch. For non-guarded models
* (or when disabled) this is a transparent pass-through. For guarded models it injects a
* guard abort signal into the provider call so a detected loop tears down the
* upstream, then wraps the returned stream. The guard only raises the retryable
* stall; bounding the re-samples and the final cook pass lives in the
* result-awaiting caller.
* Apply the loop guard around a provider dispatch. Unless explicitly disabled,
* every model gets exact suffix-cycle detection; Gemini, DeepSeek, and Grok also
* get the semantic heuristics selected by {@link isLoopGuardedModel}. The guard
* injects an abort signal into the provider call so a detected loop tears down
* the upstream, then wraps the returned stream. Bounding result-path re-samples
* lives in the result-awaiting caller.
*/
export function withThinkingLoopGuard<
O extends { signal?: AbortSignal; loopGuard?: { enabled?: boolean; checkAssistantContent?: boolean } },
@@ -429,7 +459,7 @@ export function withThinkingLoopGuard<
options: O | undefined,
dispatch: (options: O | undefined) => AssistantMessageEventStream,
): AssistantMessageEventStream {
if (process.env.PI_NO_THINKING_LOOP_GUARD === "1" || !isLoopGuardedModel(model, options)) {
if (process.env.PI_NO_THINKING_LOOP_GUARD === "1" || options?.loopGuard?.enabled === false) {
return dispatch(options);
}
const controller = new AbortController();
@@ -467,31 +497,34 @@ function buildThinkingLoopError(model: Model<Api>, detail: string): AssistantMes
}
/**
* Detect a short unit repeated back-to-back at the tail (verbatim loop). Only a
* unit carrying a letter or pictographic emoji counts — runs of digits,
* whitespace or punctuation are legitimate in tabular / hex / numeric output.
* Detect an exact cycle at the text suffix. A Z-array over the reversed tail
* finds every possible suffix period in linear time without substring churn.
* Short cycles retain the original 180-character/four-repeat sensitivity; long
* cycles require at least three repeats and 1024 repeated characters.
*/
function detectVerbatimRepetition(text: string): [unit: string, count: number] | null {
if (text.length < VERBATIM_MIN_REPEATED_CHARS) return null;
const windowSize = Math.min(text.length, VERBATIM_TAIL_WINDOW);
const searchSpace = text.slice(-windowSize);
for (let len = 2; len <= VERBATIM_MAX_UNIT; len++) {
if (searchSpace.length < len * 4) continue;
const unit = searchSpace.slice(-len);
if (!/[\p{L}\p{Extended_Pictographic}]/u.test(unit)) continue;
let count = 0;
let pos = searchSpace.length;
while (pos >= len) {
if (searchSpace.slice(pos - len, pos) === unit) {
count++;
pos -= len;
} else {
break;
}
function detectExactSuffixCycle(text: string): [unit: string, count: number] | null {
if (text.length < EXACT_SHORT_MIN_REPEATED_CHARS) return null;
const reversed = text.split("").reverse().join("");
const z = new Uint16Array(reversed.length);
let left = 0;
let right = 0;
for (let i = 1; i < reversed.length; i++) {
if (i <= right) z[i] = Math.min(right - i + 1, z[i - left]);
while (i + z[i] < reversed.length && reversed[z[i]] === reversed[i + z[i]]) z[i]++;
if (i + z[i] - 1 > right) {
left = i;
right = i + z[i] - 1;
}
if (count >= 4 && len * count >= VERBATIM_MIN_REPEATED_CHARS) return [unit, count];
}
const maxUnit = Math.min(EXACT_MAX_UNIT, Math.floor(reversed.length / 3));
for (let len = 2; len <= maxUnit; len++) {
const count = 1 + Math.floor(z[len] / len);
const minCount = len <= EXACT_SHORT_MAX_UNIT ? 4 : 3;
const minChars = len <= EXACT_SHORT_MAX_UNIT ? EXACT_SHORT_MIN_REPEATED_CHARS : EXACT_LONG_MIN_REPEATED_CHARS;
if (count < minCount || len * count < minChars) continue;
const unit = text.slice(-len);
if (/\p{L}|\p{Extended_Pictographic}/u.test(unit)) return [unit, count];
}
return null;
}
@@ -26,17 +26,15 @@ afterEach(() => {
clearCustomApis();
});
describe("auth-gateway non-streaming thinking-loop cook", () => {
it("returns 200 with cooked output instead of a 502 when the model loops", async () => {
describe("auth-gateway non-streaming thinking-loop retries", () => {
it("returns an error after three guarded looping attempts", async () => {
registerMockApi();
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "gw-thinking-loop-"));
const storage = await AuthStorage.create(path.join(dir, "auth.db"));
storage.setRuntimeApiKey("openrouter", "test-key");
const mock = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" });
// Three guarded attempts stall on the thinking loop; the fourth (cook) pass
// runs with the guard disabled and returns the visible answer.
for (let i = 0; i < 4; i++) {
mock.push({ content: [{ type: "thinking", thinking: loopThinking() }, "Final answer after cooking."] });
mock.push({ content: [{ type: "thinking", thinking: loopThinking() }, "Unreachable cooked answer."] });
}
const waitSpy = spyOn(scheduler, "wait").mockResolvedValue(undefined);
const handle = startAuthGateway({
@@ -56,18 +54,12 @@ describe("auth-gateway non-streaming thinking-loop cook", () => {
stream: false,
}),
});
const body = (await res.json()) as {
error?: unknown;
choices?: Array<{ message?: { content?: string | null } }>;
};
const body = (await res.json()) as { error?: unknown };
expect(res.status).toBe(200);
expect(body.error).toBeUndefined();
expect(body.choices?.[0]?.message?.content).toContain("Final answer after cooking.");
// Three guarded stalls + one unguarded cook pass.
expect(mock.calls).toHaveLength(4);
expect(mock.calls[0]?.options?.loopGuard?.enabled).toBeUndefined();
expect(mock.calls[3]?.options?.loopGuard?.enabled).toBe(false);
expect(res.status).toBe(502);
expect(body.error).toBeDefined();
expect(mock.calls).toHaveLength(3);
expect(mock.calls.every(call => call.options?.loopGuard?.enabled !== false)).toBe(true);
} finally {
waitSpy.mockRestore();
await handle.close();
@@ -101,7 +93,7 @@ describe("auth-gateway non-streaming thinking-loop cook", () => {
}),
});
// A genuine error is never a loop stall, so the cook fallback must not mask it.
// A genuine error is never a loop stall, so loop retry handling must not mask it.
expect(res.status).toBe(502);
expect(mock.calls).toHaveLength(1);
expect(THINKING_LOOP_ERROR_MARKER.length).toBeGreaterThan(0);
+158 -29
View File
@@ -1,6 +1,6 @@
import { describe, expect, spyOn, test } from "bun:test";
import { scheduler } from "node:timers/promises";
import { clearCustomApis } from "@oh-my-pi/pi-ai/api-registry";
import { clearCustomApis, registerCustomApi } from "@oh-my-pi/pi-ai/api-registry";
import * as AIError from "@oh-my-pi/pi-ai/error";
import { createMockModel, type MockContent, registerMockApi } from "@oh-my-pi/pi-ai/providers/mock";
import { complete, completeSimple, stream, streamSimple } from "@oh-my-pi/pi-ai/stream";
@@ -42,6 +42,11 @@ function nearDuplicateLoop(paragraphs: number): string {
return out.join("\n\n\n");
}
/** The exact 311-character cycle observed from Kiro gpt-5-6-sol on 2026-08-14.
* The persisted assistant message repeated this cycle 58 times before abort. */
const OBSERVED_KIRO_CYCLE =
"% shipped. 100% delivered. 100% verified. 100% validated. 100% approved. 100% accepted. 100% merged. 100% deployed. 100% live. 100% operational. 100% successful. 100% excellent. 100% perfect. 100% final. 100% absolute. 100% total. 100% whole. 100% full. 100% entire. 100% complete. 100% done. 100% finished. 100";
/** Genuinely distinct reasoning paragraphs — must never trip the detector. */
function distinctReasoning(): string {
return [
@@ -335,7 +340,7 @@ describe("thinking-loop guard (stream wrapper)", () => {
});
}
test("emits no observable thinking/text content before the error terminal", async () => {
test("drops the failed attempt from the terminal after a loop is detected", async () => {
registerMockApi();
try {
const mock = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" });
@@ -344,9 +349,11 @@ describe("thinking-loop guard (stream wrapper)", () => {
const events = await collect(stream(mock.model, context()));
const terminal = events.at(-1);
expect(terminal?.type).toBe("error");
// The guard must not forward the looping thinking_end / done.
// A prefix can stream before detection, but the failed terminal must not
// carry replayable content or forward the normal completion boundary.
expect(events.some(e => e.type === "thinking_end")).toBe(false);
expect(events.some(e => e.type === "done")).toBe(false);
if (terminal?.type === "error") expect(terminal.error.content).toEqual([]);
} finally {
clearCustomApis();
}
@@ -367,6 +374,75 @@ describe("thinking-loop guard (stream wrapper)", () => {
}
});
test("terminates the observed long-cycle Kiro text runaway", async () => {
registerMockApi();
try {
const mock = createMockModel({ provider: "kiro", id: "gpt-5-6-sol" });
mock.push({ content: [`Healthy lead sentence. ${OBSERVED_KIRO_CYCLE.repeat(6)}`] });
const result = await stream(mock.model, context()).result();
expect(result.stopReason).toBe("error");
expect(result.content).toEqual([]);
expect(result.errorMessage).toContain(THINKING_LOOP_ERROR_MARKER);
expect(AIError.is(result.errorId, AIError.Flag.ThinkingLoop)).toBe(true);
} finally {
clearCustomApis();
}
});
test("detects the Kiro cycle across token-sized synthetic-provider deltas", async () => {
const model = {
api: "openai-completions",
provider: "synthetic",
id: "gpt-5-6-sol",
name: "GPT 5.6 Sol",
baseUrl: "https://unused.example.com",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 32_768,
} as Model<"openai-completions">;
const text = `Healthy lead sentence. ${OBSERVED_KIRO_CYCLE.repeat(6)}`;
const fetch = async (): Promise<Response> => {
const events: string[] = [];
for (let i = 0; i < text.length; i += 23) {
events.push(
JSON.stringify({
id: "cycle",
object: "chat.completion.chunk",
created: 0,
model: model.id,
choices: [{ index: 0, delta: { content: text.slice(i, i + 23) } }],
}),
);
}
events.push(
JSON.stringify({
id: "cycle",
object: "chat.completion.chunk",
created: 0,
model: model.id,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
}),
"[DONE]",
);
return new Response(`${events.map(event => `data: ${event}`).join("\n\n")}\n\n`, {
headers: { "content-type": "text/event-stream" },
});
};
const events = await collect(streamSimple(model, context(), { apiKey: "test", fetch }));
const terminal = events.at(-1);
expect(events.some(event => event.type === "text_delta")).toBe(true);
expect(terminal?.type).toBe("error");
if (terminal?.type !== "error") throw new Error("expected loop error terminal");
expect(terminal.error.content).toEqual([]);
expect(AIError.is(terminal.error.errorId, AIError.Flag.ThinkingLoop)).toBe(true);
});
test("does not trip on a healthy gemini turn that reasons then answers", async () => {
registerMockApi();
try {
@@ -636,12 +712,12 @@ describe("GeminiHeaderRunDetector", () => {
});
});
describe("thinking-loop cook fallback (result path)", () => {
describe("thinking-loop retry budget (result path)", () => {
function loopResponse(): { content: MockContent[] } {
return { content: [{ type: "thinking", thinking: nearDuplicateLoop(12) }] };
}
test("completeSimple re-samples a loop then cooks through with the guard disabled", async () => {
test("completeSimple fails closed after three guarded attempts", async () => {
registerMockApi();
const waitSpy = spyOn(scheduler, "wait").mockResolvedValue(undefined);
try {
@@ -650,21 +726,18 @@ describe("thinking-loop cook fallback (result path)", () => {
const result = await completeSimple(mock.model, context());
// Three guarded attempts raise the stall; the fourth (guard disabled) cooks through.
expect(mock.calls).toHaveLength(4);
expect(result.stopReason).toBe("stop");
expect(result.content.some(block => block.type === "thinking")).toBe(true);
expect(result.errorMessage).toBeUndefined();
// First three dispatches are guarded; only the final cook pass disables it.
expect(mock.calls[0]?.options?.loopGuard?.enabled).toBeUndefined();
expect(mock.calls[3]?.options?.loopGuard?.enabled).toBe(false);
expect(mock.calls).toHaveLength(3);
expect(result.stopReason).toBe("error");
expect(result.content).toEqual([]);
expect(result.errorMessage).toContain(THINKING_LOOP_ERROR_MARKER);
expect(mock.calls.every(call => call.options?.loopGuard?.enabled !== false)).toBe(true);
} finally {
waitSpy.mockRestore();
clearCustomApis();
}
});
test("complete (non-simple) also cooks through after the abort budget", async () => {
test("complete (non-simple) also fails closed after three guarded attempts", async () => {
registerMockApi();
const waitSpy = spyOn(scheduler, "wait").mockResolvedValue(undefined);
try {
@@ -673,11 +746,11 @@ describe("thinking-loop cook fallback (result path)", () => {
const result = await complete(mock.model, context());
expect(mock.calls).toHaveLength(4);
expect(result.stopReason).toBe("stop");
expect(result.errorMessage).toBeUndefined();
expect(mock.calls[0]?.options?.loopGuard?.enabled).toBeUndefined();
expect(mock.calls[3]?.options?.loopGuard?.enabled).toBe(false);
expect(mock.calls).toHaveLength(3);
expect(result.stopReason).toBe("error");
expect(result.content).toEqual([]);
expect(result.errorMessage).toContain(THINKING_LOOP_ERROR_MARKER);
expect(mock.calls.every(call => call.options?.loopGuard?.enabled !== false)).toBe(true);
} finally {
waitSpy.mockRestore();
clearCustomApis();
@@ -706,23 +779,79 @@ describe("thinking-loop cook fallback (result path)", () => {
}
});
test("does not retry a contentful marker error (replay-unsafe output)", async () => {
test("a caller abort after the third guarded result supersedes the loop error", async () => {
registerMockApi();
const controller = new AbortController();
const waitSpy = spyOn(scheduler, "wait").mockResolvedValue(undefined);
try {
const mock = createMockModel({ provider: "openrouter", id: "google/gemini-3.5-flash" });
mock.push({
content: ["Looping visible reasoning garbage."],
stopReason: "error",
errorMessage: `${THINKING_LOOP_ERROR_MARKER}: already streamed, non-retryable`,
let attempts = 0;
const mock = createMockModel({
provider: "openrouter",
id: "google/gemini-3.5-flash",
handler: () => {
if (++attempts === 3) controller.abort(new Error("cancelled after final result"));
return loopResponse();
},
});
const result = await completeSimple(mock.model, context());
await expect(completeSimple(mock.model, context(), { signal: controller.signal })).rejects.toThrow(
"cancelled after final result",
);
expect(mock.calls).toHaveLength(3);
} finally {
waitSpy.mockRestore();
clearCustomApis();
}
});
// Visible content already escaped: the marker error is returned as-is, never re-sampled.
expect(mock.calls).toHaveLength(1);
test("does not retry a contentful ThinkingLoop error (replay-unsafe output)", async () => {
const api = "contentful-loop-test";
let calls = 0;
const model = {
api,
provider: "test",
id: "test-model",
name: "Test model",
baseUrl: "test://",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000,
maxTokens: 100,
} as unknown as Model<Api>;
registerCustomApi(api, () => {
calls++;
const inner = new AssistantMessageEventStream();
const error: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Looping visible reasoning garbage." }],
api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "error",
errorMessage: `${THINKING_LOOP_ERROR_MARKER}: already streamed, non-retryable`,
errorId: AIError.create(AIError.Flag.ThinkingLoop),
timestamp: Date.now(),
};
inner.push({ type: "error", reason: "error", error });
return inner;
});
const waitSpy = spyOn(scheduler, "wait").mockResolvedValue(undefined);
try {
const result = await completeSimple(model, context());
expect(calls).toBe(1);
expect(result.stopReason).toBe("error");
expect(result.errorMessage).toContain(THINKING_LOOP_ERROR_MARKER);
expect(result.content).toHaveLength(1);
expect(AIError.is(result.errorId, AIError.Flag.ThinkingLoop)).toBe(true);
} finally {
waitSpy.mockRestore();
clearCustomApis();