diff --git a/src/review/rag.ts b/src/review/rag.ts index 20792b7a9f..bc5a0708eb 100644 --- a/src/review/rag.ts +++ b/src/review/rag.ts @@ -323,6 +323,23 @@ export async function getStoredChunkMeta(storage: StorageAdapter, project: strin } // ── Embedding (fail-safe: null on any failure) ──────────────────────────────────────────────────── +// bge-m3's actual limit is a TOKEN budget (8192), but CHUNK_CHARS is a coarse character-count proxy for it — +// dense/minified content (few spaces to "waste" per token) can exceed the real token budget despite staying +// under the character budget, which is the observed cause of production ai_embed_http_400s (GITTENSORY-D, +// #4996/#5046 history above). 4000 chars stays safely under 8192 tokens even at a pessimistic ~1 char/token +// ratio, so a single retry at this length either succeeds (a truncated-but-present vector beats losing the +// chunk's RAG signal entirely) or definitively confirms the text just isn't embeddable at any size we'd try. +const CONTEXT_OVERFLOW_RETRY_CHARS = 4000; + +/** True when an embed failure is specifically a provider-reported context/input-length overflow (as opposed + * to a network error, an auth failure, or any other 4xx) — see src/selfhost/ai.ts's `ai_embed_http_: + * ` error shape. Deliberately narrow (matches the literal phrase self-host Ollama/OpenAI-compatible + * embedding endpoints use) so a truncate-and-retry is only attempted for the one failure mode it can + * actually fix; any other error still fails fast via the existing single-attempt path below. */ +function isEmbedContextLengthError(error: unknown): boolean { + return /context length|context_length|maximum context|too long/i.test(String(error)); +} + /** Embed one text in isolation — the fallback when a batch call throws or comes back structurally invalid, so * the caller can isolate exactly which item(s) are the problem instead of losing every chunk in the batch. * WARN, not error: a per-item failure here is expected diagnostic detail, already summarized once per @@ -337,6 +354,12 @@ async function embedSingleText(inference: InferenceAdapter, text: string, expect console.warn(JSON.stringify({ level: "warn", event: "rag_embed_item_invalid", chars: text.length })); return null; } catch (error) { + // GITTENSORY-D: a context-length overflow on an oversized/dense chunk gets exactly one retry at a + // conservatively truncated length instead of being dropped outright — see CONTEXT_OVERFLOW_RETRY_CHARS. + if (isEmbedContextLengthError(error) && text.length > CONTEXT_OVERFLOW_RETRY_CHARS) { + console.warn(JSON.stringify({ level: "warn", event: "rag_embed_item_truncate_retry", chars: text.length, retryChars: CONTEXT_OVERFLOW_RETRY_CHARS })); + return embedSingleText(inference, text.slice(0, CONTEXT_OVERFLOW_RETRY_CHARS), expectedDimensions); + } console.warn(JSON.stringify({ level: "warn", event: "rag_embed_item_error", chars: text.length, message: String(error).slice(0, 200) })); return null; } diff --git a/test/unit/rag.test.ts b/test/unit/rag.test.ts index 84b29b6db3..1cde29a07c 100644 --- a/test/unit/rag.test.ts +++ b/test/unit/rag.test.ts @@ -951,6 +951,90 @@ describe("rag: embedTexts validation branches", () => { errorSpy.mockRestore(); }); + it("REGRESSION (GITTENSORY-D): a context-length overflow on an OVERSIZED item retries once at a truncated length and succeeds", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const bigText = "x".repeat(5000); // > CONTEXT_OVERFLOW_RETRY_CHARS(4000), so the truncate-retry guard fires + const inference: InferenceAdapter = { + run: async (_model, options) => { + const batch = (options as { text: string[] }).text; + const [text] = batch; + if (batch.length === 1 && text && text.length > 4000) { + throw new Error("ai_embed_http_400: the input length exceeds the context length"); + } + return { data: batch.map(() => Array(1024).fill(0.1)) }; + }, + }; + // A single-item embedTexts call goes straight to the batch path (batchSize defaults to 96, so one text + // never needs the per-item retry split) -- it throws on the oversized text, then embedTexts' own + // per-item-retry loop calls embedSingleText, which is where the NEW truncate-and-retry logic lives. + const out = await embedTexts(inference, [bigText]); + expect(out).toEqual([Array(1024).fill(0.1)]); + expect(warnSpy.mock.calls.some((c) => typeof c[0] === "string" && c[0].includes("rag_embed_item_truncate_retry"))).toBe(true); + // Truncated success still means the batch was "degraded" (the initial full-length attempt failed), but + // the item itself is NOT counted as failed since it ultimately got a real vector. + expect(errorSpy.mock.calls.some((c) => typeof c[0] === "string" && c[0].includes("rag_embed_batch_degraded"))).toBe(false); + warnSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it("REGRESSION (GITTENSORY-D): a context-length overflow that ALSO fails truncated still gives up after exactly one retry (no infinite loop)", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + const bigText = "x".repeat(5000); + let calls = 0; + const inference: InferenceAdapter = { + run: async () => { + calls += 1; + throw new Error("ai_embed_http_400: the input length exceeds the context length"); + }, + }; + const out = await embedTexts(inference, [bigText]); + expect(out).toEqual([null]); + // batch attempt (1) + per-item retry at full length (1) + truncated retry (1) = 3, never more (the + // truncated length is exactly CONTEXT_OVERFLOW_RETRY_CHARS, so the `text.length > ...` guard stops it + // from truncating again). + expect(calls).toBe(3); + expect(warnSpy.mock.calls.some((c) => typeof c[0] === "string" && c[0].includes("rag_embed_item_truncate_retry"))).toBe(true); + expect(warnSpy.mock.calls.some((c) => typeof c[0] === "string" && c[0].includes("rag_embed_item_error"))).toBe(true); + vi.restoreAllMocks(); + }); + + it("does NOT truncate-retry a non-context-length error, even on an oversized text (only the specific failure mode this can fix gets a retry)", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + const bigText = "x".repeat(5000); + let calls = 0; + const inference: InferenceAdapter = { + run: async () => { + calls += 1; + throw new Error("ai_embed_http_503: upstream unavailable"); + }, + }; + const out = await embedTexts(inference, [bigText]); + expect(out).toEqual([null]); + expect(calls).toBe(2); // batch attempt + one per-item retry at FULL length, no truncate-retry + expect(warnSpy.mock.calls.some((c) => typeof c[0] === "string" && c[0].includes("rag_embed_item_truncate_retry"))).toBe(false); + vi.restoreAllMocks(); + }); + + it("does NOT truncate-retry a context-length error on a SHORT text (already at/under the retry length, nothing smaller to try)", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + let calls = 0; + const inference: InferenceAdapter = { + run: async () => { + calls += 1; + throw new Error("ai_embed_http_400: the input length exceeds the context length"); + }, + }; + const out = await embedTexts(inference, ["short"]); + expect(out).toEqual([null]); + expect(calls).toBe(2); // batch attempt + one per-item retry at full length; "short".length(5) is not > 4000 + expect(warnSpy.mock.calls.some((c) => typeof c[0] === "string" && c[0].includes("rag_embed_item_truncate_retry"))).toBe(false); + vi.restoreAllMocks(); + }); + it("logs nothing when a batch-level glitch self-heals — every item succeeds once retried individually (failedCount === 0)", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); let batchCalls = 0;