mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 13:11:40 +00:00
perf: extract memory multimodal indexing policy
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user