fix(ai): stop runaway repeated responses
This commit is contained in:
+59
-57
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user