perf: extract memory multimodal indexing policy

This commit is contained in:
Peter Steinberger
2026-04-07 00:15:44 +01:00
parent d2a03eca1a
commit 3a1ca98e53
5 changed files with 31 additions and 80 deletions

View File

@@ -1,4 +1,3 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, rmSync } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
@@ -319,78 +318,6 @@ describe("memory index", () => {
expect(audioResults.some((result) => result.path.endsWith("meeting.wav"))).toBe(true);
});
it("skips oversized multimodal inputs without aborting sync", async () => {
const mediaDir = path.join(workspaceDir, "media-oversize");
await fs.mkdir(mediaDir, { recursive: true });
await fs.writeFile(path.join(mediaDir, "huge.png"), Buffer.alloc(7000, 1));
const cfg = createCfg({
storePath: path.join(workspaceDir, `index-oversize-${randomUUID()}.sqlite`),
provider: "gemini",
model: "gemini-embedding-2-preview",
extraPaths: [mediaDir],
multimodal: { enabled: true, modalities: ["image"] },
});
const manager = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
await manager.sync({ reason: "test" });
expect(embedBatchInputCalls).toBeGreaterThan(0);
const imageResults = await manager.search("image");
expect(imageResults.some((result) => result.path.endsWith("huge.png"))).toBe(false);
const alphaResults = await manager.search("alpha");
expect(alphaResults.some((result) => result.path.endsWith("memory/2026-01-12.md"))).toBe(true);
await manager.close?.();
});
it("reindexes a multimodal file after a transient mid-sync disappearance", async () => {
const mediaDir = path.join(workspaceDir, "media-race");
const imagePath = path.join(mediaDir, "diagram.png");
await fs.mkdir(mediaDir, { recursive: true });
await fs.writeFile(imagePath, Buffer.from("png"));
const cfg = createCfg({
storePath: path.join(workspaceDir, `index-race-${randomUUID()}.sqlite`),
provider: "gemini",
model: "gemini-embedding-2-preview",
extraPaths: [mediaDir],
multimodal: { enabled: true, modalities: ["image"] },
});
const manager = requireManager(await getMemorySearchManager({ cfg, agentId: "main" }));
const realReadFile = fs.readFile.bind(fs);
let imageReads = 0;
const readSpy = vi.spyOn(fs, "readFile").mockImplementation(async (...args) => {
const [targetPath] = args;
if (typeof targetPath === "string" && targetPath === imagePath) {
imageReads += 1;
if (imageReads === 2) {
const err = Object.assign(
new Error(`ENOENT: no such file or directory, open '${imagePath}'`),
{
code: "ENOENT",
},
) as NodeJS.ErrnoException;
throw err;
}
}
return await realReadFile(...args);
});
await manager.sync({ reason: "test" });
readSpy.mockRestore();
const callsAfterFirstSync = embedBatchInputCalls;
(manager as unknown as { dirty: boolean }).dirty = true;
await manager.sync({ reason: "test" });
expect(embedBatchInputCalls).toBeGreaterThan(callsAfterFirstSync);
const results = await manager.search("image");
expect(results.some((result) => result.path.endsWith("diagram.png"))).toBe(true);
await manager.close?.();
});
it.skip("finds keyword matches via hybrid search when query embedding is zero", async () => {
await expectHybridKeywordSearchFindsMemory(
createCfg({

View File

@@ -506,12 +506,6 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
this.db.prepare(`DELETE FROM files WHERE path = ? AND source = ?`).run(pathname, source);
}
private isStructuredInputTooLargeError(message: string): boolean {
return /(413|payload too large|request too large|input too large|too many tokens|input limit|request size)/i.test(
message,
);
}
/**
* Write chunks (and optional embeddings) for a file into the index.
* Handles both the chunks table, the vector table, and the FTS table.
@@ -646,7 +640,9 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
if (
"kind" in entry &&
entry.kind === "multimodal" &&
this.isStructuredInputTooLargeError(message)
/(413|payload too large|request too large|input too large|too many tokens|input limit|request size)/i.test(
message,
)
) {
log.warn("memory embeddings: skipping multimodal file rejected as too large", {
path: entry.path,

View File

@@ -3,6 +3,7 @@ import {
buildMemoryEmbeddingBatches,
filterNonEmptyMemoryChunks,
isRetryableMemoryEmbeddingError,
isStructuredInputTooLargeMemoryEmbeddingError,
resolveMemoryEmbeddingRetryDelay,
runMemoryEmbeddingRetryLoop,
} from "./manager-embedding-policy.js";
@@ -95,6 +96,16 @@ describe("memory embedding policy", () => {
expect(waits).toEqual([500]);
});
it("classifies oversized structured-input errors", () => {
expect(isStructuredInputTooLargeMemoryEmbeddingError("payload too large")).toBe(true);
expect(
isStructuredInputTooLargeMemoryEmbeddingError(
"gemini embeddings failed: request size exceeded input limit",
),
).toBe(true);
expect(isStructuredInputTooLargeMemoryEmbeddingError("connection reset by peer")).toBe(false);
});
it("caps retry jittered delays", () => {
expect(resolveMemoryEmbeddingRetryDelay(500, 0, 8000)).toBe(500);
expect(resolveMemoryEmbeddingRetryDelay(500, 1, 8000)).toBe(600);

View File

@@ -84,6 +84,12 @@ export function isRetryableMemoryEmbeddingError(message: string): boolean {
);
}
export function isStructuredInputTooLargeMemoryEmbeddingError(message: string): boolean {
return /(413|payload too large|request too large|input too large|too many tokens|input limit|request size)/i.test(
message,
);
}
export function resolveMemoryEmbeddingRetryDelay(
delayMs: number,
randomValue: number,

View File

@@ -243,6 +243,17 @@ describe("buildFileEntry", () => {
await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull();
});
it("skips lazy multimodal indexing when the file disappears before loading bytes", async () => {
const tmpDir = getTmpDir();
const target = path.join(tmpDir, "diagram.png");
await fs.writeFile(target, Buffer.from("png"));
const entry = await buildFileEntry(target, tmpDir, multimodal);
await fs.rm(target);
await expect(buildMultimodalChunkForIndexing(entry!)).resolves.toBeNull();
});
});
describe("chunkMarkdown", () => {