diff --git a/packages/ai/src/stream.ts b/packages/ai/src/stream.ts index 67e576309..c75175e7c 100644 --- a/packages/ai/src/stream.ts +++ b/packages/ai/src/stream.ts @@ -1022,45 +1022,47 @@ function streamDispatch( } } -/** 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 { 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( @@ -1068,11 +1070,7 @@ export async function complete( context: Context, options?: OptionsForApi, ): Promise { - 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( // 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( // 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( context: Context, options?: SimpleStreamOptions, ): Promise { - 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; diff --git a/packages/ai/src/utils/thinking-loop.ts b/packages/ai/src/utils/thinking-loop.ts index 25e8a493b..cefc033cf 100644 --- a/packages/ai/src/utils/thinking-loop.ts +++ b/packages/ai/src/utils/thinking-loop.ts @@ -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, options?: StreamOptions): boolean { if (options?.loopGuard?.enabled === false) return false; @@ -120,8 +131,10 @@ export function isLoopGuardedModel(model: Model, 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[] = []; + 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, 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; } diff --git a/packages/ai/test/auth-gateway-thinking-loop.test.ts b/packages/ai/test/auth-gateway-thinking-loop.test.ts index e2e623b64..d0b274787 100644 --- a/packages/ai/test/auth-gateway-thinking-loop.test.ts +++ b/packages/ai/test/auth-gateway-thinking-loop.test.ts @@ -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); diff --git a/packages/ai/test/thinking-loop.test.ts b/packages/ai/test/thinking-loop.test.ts index 5ff74df21..477cccab2 100644 --- a/packages/ai/test/thinking-loop.test.ts +++ b/packages/ai/test/thinking-loop.test.ts @@ -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 => { + 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; + 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();