Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/review/rag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_<status>:
* <body>` 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
Expand All @@ -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;
}
Expand Down
84 changes: 84 additions & 0 deletions test/unit/rag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down