From fcacf03c4c863f7d2d0c3f217f85a84683e3d577 Mon Sep 17 00:00:00 2001 From: liangzhechen655 <186018027+liangzhechen655@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:51:50 +0800 Subject: [PATCH 1/6] fix(core): isolate in-memory vector search results --- .changeset/clean-vector-search-results.md | 5 +++ .../memory/adapters/vector/in-memory.spec.ts | 44 +++++++++++++++++++ .../src/memory/adapters/vector/in-memory.ts | 2 + 3 files changed, 51 insertions(+) create mode 100644 .changeset/clean-vector-search-results.md create mode 100644 packages/core/src/memory/adapters/vector/in-memory.spec.ts diff --git a/.changeset/clean-vector-search-results.md b/.changeset/clean-vector-search-results.md new file mode 100644 index 000000000..141bd9e1b --- /dev/null +++ b/.changeset/clean-vector-search-results.md @@ -0,0 +1,5 @@ +--- +"@voltagent/core": patch +--- + +Copy embeddings and metadata in in-memory vector search results so caller mutations do not corrupt stored memories or change subsequent user-filtered searches. diff --git a/packages/core/src/memory/adapters/vector/in-memory.spec.ts b/packages/core/src/memory/adapters/vector/in-memory.spec.ts new file mode 100644 index 000000000..85b5395c0 --- /dev/null +++ b/packages/core/src/memory/adapters/vector/in-memory.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { InMemoryVectorAdapter } from "./in-memory"; + +describe("InMemoryVectorAdapter search result isolation", () => { + it("does not let a returned embedding corrupt future similarity searches", async () => { + const adapter = new InMemoryVectorAdapter(); + await adapter.store("matching", [1, 0], { userId: "alice" }); + await adapter.store("other", [0, 1], { userId: "alice" }); + + const [result] = await adapter.search([1, 0]); + result.vector[0] = 0; + result.vector[1] = 1; + + expect((await adapter.get("matching"))?.vector).toEqual([1, 0]); + const [nextResult] = await adapter.search([1, 0]); + expect(nextResult.id).toBe("matching"); + expect(nextResult.score).toBe(1); + }); + + it("does not let returned metadata change user-scoped retrieval", async () => { + const adapter = new InMemoryVectorAdapter(); + await adapter.store("memory", [1, 0], { userId: "alice" }); + + const [result] = await adapter.search([1, 0], { filter: { userId: "alice" } }); + if (!result.metadata) throw new Error("Expected stored metadata"); + result.metadata.userId = "bob"; + + expect(await adapter.search([1, 0], { filter: { userId: "bob" } })).toEqual([]); + expect(await adapter.search([1, 0], { filter: { userId: "alice" } })).toHaveLength(1); + expect((await adapter.get("memory"))?.metadata).toEqual({ userId: "alice" }); + }); + + it("supports results without metadata and keeps stored input isolated", async () => { + const adapter = new InMemoryVectorAdapter(); + const vector = [1, 0]; + await adapter.store("memory", vector); + vector[0] = 0; + + const [result] = await adapter.search([1, 0]); + expect(result.vector).toEqual([1, 0]); + expect(result.metadata).toBeUndefined(); + expect(result.score).toBe(1); + }); +}); diff --git a/packages/core/src/memory/adapters/vector/in-memory.ts b/packages/core/src/memory/adapters/vector/in-memory.ts index c2d0930d3..378964d1f 100644 --- a/packages/core/src/memory/adapters/vector/in-memory.ts +++ b/packages/core/src/memory/adapters/vector/in-memory.ts @@ -74,6 +74,8 @@ export class InMemoryVectorAdapter implements VectorAdapter { if (score >= threshold) { results.push({ ...item, + vector: [...item.vector], + metadata: item.metadata ? { ...item.metadata } : undefined, score, distance: 1 - similarity, // Convert to distance metric }); From d7141aa64f7850c7b7c2f89dbbe5144114fce229 Mon Sep 17 00:00:00 2001 From: liangzhechen655 <186018027+liangzhechen655@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:02:10 +0800 Subject: [PATCH 2/6] fix(core): deep-clone vector metadata at storage boundaries --- .changeset/clean-vector-search-results.md | 2 +- .../memory/adapters/vector/in-memory.spec.ts | 22 +++++++++++++++++++ .../src/memory/adapters/vector/in-memory.ts | 13 ++++++----- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.changeset/clean-vector-search-results.md b/.changeset/clean-vector-search-results.md index 141bd9e1b..7705333bc 100644 --- a/.changeset/clean-vector-search-results.md +++ b/.changeset/clean-vector-search-results.md @@ -2,4 +2,4 @@ "@voltagent/core": patch --- -Copy embeddings and metadata in in-memory vector search results so caller mutations do not corrupt stored memories or change subsequent user-filtered searches. +Copy embeddings in in-memory vector search results and deep-clone metadata at the store, get and search boundaries so caller mutations do not corrupt stored memories or change subsequent user-filtered searches. Only selected search results are cloned. diff --git a/packages/core/src/memory/adapters/vector/in-memory.spec.ts b/packages/core/src/memory/adapters/vector/in-memory.spec.ts index 85b5395c0..861ab5780 100644 --- a/packages/core/src/memory/adapters/vector/in-memory.spec.ts +++ b/packages/core/src/memory/adapters/vector/in-memory.spec.ts @@ -2,6 +2,28 @@ import { describe, expect, it } from "vitest"; import { InMemoryVectorAdapter } from "./in-memory"; describe("InMemoryVectorAdapter search result isolation", () => { + it.each(["search", "get", "store"])( + "isolates nested metadata at the %s boundary", + async (operation) => { + const adapter = new InMemoryVectorAdapter(); + const metadata = { context: { labels: ["original"] } }; + await adapter.store("memory", [1, 0], metadata); + + if (operation === "store") { + metadata.context.labels.push("changed"); + } else { + const result = + operation === "search" ? (await adapter.search([1, 0]))[0] : await adapter.get("memory"); + const context = result?.metadata?.context as typeof metadata.context; + context.labels.push("changed"); + } + + expect((await adapter.get("memory"))?.metadata).toEqual({ + context: { labels: ["original"] }, + }); + }, + ); + it("does not let a returned embedding corrupt future similarity searches", async () => { const adapter = new InMemoryVectorAdapter(); await adapter.store("matching", [1, 0], { userId: "alice" }); diff --git a/packages/core/src/memory/adapters/vector/in-memory.ts b/packages/core/src/memory/adapters/vector/in-memory.ts index 378964d1f..7a163b820 100644 --- a/packages/core/src/memory/adapters/vector/in-memory.ts +++ b/packages/core/src/memory/adapters/vector/in-memory.ts @@ -1,3 +1,4 @@ +import { deepClone } from "@voltagent/internal/utils"; import { cosineSimilarity } from "../../utils/vector-math"; import type { SearchResult, VectorAdapter, VectorItem } from "./types"; @@ -26,7 +27,7 @@ export class InMemoryVectorAdapter implements VectorAdapter { this.vectors.set(id, { id, vector: [...vector], // Clone to prevent external modifications - metadata: metadata ? { ...metadata } : undefined, + metadata: metadata ? deepClone(metadata) : undefined, }); } @@ -74,8 +75,6 @@ export class InMemoryVectorAdapter implements VectorAdapter { if (score >= threshold) { results.push({ ...item, - vector: [...item.vector], - metadata: item.metadata ? { ...item.metadata } : undefined, score, distance: 1 - similarity, // Convert to distance metric }); @@ -85,7 +84,11 @@ export class InMemoryVectorAdapter implements VectorAdapter { // Sort by score (descending) and limit results results.sort((a, b) => b.score - a.score); - return results.slice(0, limit); + return results.slice(0, limit).map((item) => ({ + ...item, + vector: [...item.vector], + metadata: item.metadata ? deepClone(item.metadata) : undefined, + })); } async delete(id: string): Promise { @@ -117,7 +120,7 @@ export class InMemoryVectorAdapter implements VectorAdapter { return { ...item, vector: [...item.vector], - metadata: item.metadata ? { ...item.metadata } : undefined, + metadata: item.metadata ? deepClone(item.metadata) : undefined, }; } From 8c41f958ebc365e37cf6de0abc9ffee72fc6dc9f Mon Sep 17 00:00:00 2001 From: liangzhechen655 <186018027+liangzhechen655@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:29:32 +0800 Subject: [PATCH 3/6] fix(internal): recursively clone metadata in the fallback --- .changeset/clean-vector-search-results.md | 3 +- .../memory/adapters/vector/in-memory.spec.ts | 24 ++++++ packages/internal/src/utils/objects.spec.ts | 74 ++++++++++++++++++- packages/internal/src/utils/objects.ts | 52 +++++++++++-- 4 files changed, 145 insertions(+), 8 deletions(-) diff --git a/.changeset/clean-vector-search-results.md b/.changeset/clean-vector-search-results.md index 7705333bc..226b945d4 100644 --- a/.changeset/clean-vector-search-results.md +++ b/.changeset/clean-vector-search-results.md @@ -1,5 +1,6 @@ --- "@voltagent/core": patch +"@voltagent/internal": patch --- -Copy embeddings in in-memory vector search results and deep-clone metadata at the store, get and search boundaries so caller mutations do not corrupt stored memories or change subsequent user-filtered searches. Only selected search results are cloned. +Copy embeddings in in-memory vector search results and deep-clone metadata at the store, get and search boundaries so caller mutations do not corrupt stored memories or change subsequent user-filtered searches. Only selected search results are cloned. Make the internal deep-clone fallback recursive, preserving cycles and shared references when metadata contains functions. diff --git a/packages/core/src/memory/adapters/vector/in-memory.spec.ts b/packages/core/src/memory/adapters/vector/in-memory.spec.ts index 861ab5780..86bc7b3fc 100644 --- a/packages/core/src/memory/adapters/vector/in-memory.spec.ts +++ b/packages/core/src/memory/adapters/vector/in-memory.spec.ts @@ -2,6 +2,30 @@ import { describe, expect, it } from "vitest"; import { InMemoryVectorAdapter } from "./in-memory"; describe("InMemoryVectorAdapter search result isolation", () => { + it.each(["search", "get", "store"])( + "isolates nested metadata containing functions at the %s boundary", + async (operation) => { + const adapter = new InMemoryVectorAdapter(); + const fn = () => "label"; + const metadata = { context: { labels: ["original"] }, fn }; + await adapter.store("memory", [1, 0], metadata); + + if (operation === "store") { + metadata.context.labels.push("changed"); + } else { + const result = + operation === "search" ? (await adapter.search([1, 0]))[0] : await adapter.get("memory"); + const context = result?.metadata?.context as typeof metadata.context; + context.labels.push("changed"); + } + + expect((await adapter.get("memory"))?.metadata).toEqual({ + context: { labels: ["original"] }, + fn, + }); + }, + ); + it.each(["search", "get", "store"])( "isolates nested metadata at the %s boundary", async (operation) => { diff --git a/packages/internal/src/utils/objects.spec.ts b/packages/internal/src/utils/objects.spec.ts index bc7115066..66f473ac5 100644 --- a/packages/internal/src/utils/objects.spec.ts +++ b/packages/internal/src/utils/objects.spec.ts @@ -1,7 +1,77 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { deepClone, hasKey } from "./objects"; describe("deepClone", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("isolates nested data when functions prevent structured cloning", () => { + const shared = { labels: ["original"] }; + const original = { fn: () => "test", first: shared, second: shared }; + const cloned = deepClone(original); + cloned.first.labels.push("changed"); + expect(original.first.labels).toEqual(["original"]); + expect(cloned.second).toBe(cloned.first); + expect(cloned.fn).toBe(original.fn); + }); + + it("preserves cycles when functions prevent structured cloning", () => { + const original: { fn: () => string; self?: unknown } = { fn: () => "test" }; + original.self = original; + const cloned = deepClone(original); + expect(cloned.self).toBe(cloned); + expect(cloned.fn).toBe(original.fn); + }); + + it("deep-clones arrays and primitives when structuredClone is unavailable", () => { + vi.stubGlobal("structuredClone", undefined); + const original = [{ nested: [1, null, undefined, false, "value"] }]; + const cloned = deepClone(original); + expect(Array.isArray(cloned)).toBe(true); + expect(cloned).toEqual(original); + expect(cloned[0].nested).not.toBe(original[0].nested); + expect(deepClone(null)).toBeNull(); + expect(deepClone(undefined)).toBeUndefined(); + expect(deepClone(42)).toBe(42); + }); + + it("preserves collection types and shared references in the fallback", () => { + const shared = { label: "original" }; + const original = { + fn: () => "test", + shared, + map: new Map([[shared, shared]]), + set: new Set([shared]), + date: new Date("2023-01-01"), + regex: /test/g, + }; + const cloned = deepClone(original); + expect(cloned.map).toBeInstanceOf(Map); + expect(cloned.map.get(cloned.shared)).toBe(cloned.shared); + expect(cloned.set).toBeInstanceOf(Set); + expect(cloned.set.has(cloned.shared)).toBe(true); + expect(cloned.date).toEqual(original.date); + expect(cloned.date).not.toBe(original.date); + expect(cloned.regex).toEqual(original.regex); + expect(cloned.regex).not.toBe(original.regex); + }); + + it("copies an own __proto__ key without changing the fallback object's prototype", () => { + const original = { fn: () => "test" }; + Object.defineProperty(original, "__proto__", { + value: { nested: "original" }, + enumerable: true, + }); + const cloned = deepClone(original); + expect(Object.getPrototypeOf(cloned)).toBe(Object.prototype); + expect(Object.hasOwn(cloned, "__proto__")).toBe(true); + expect(Object.getOwnPropertyDescriptor(cloned, "__proto__")?.value).toEqual({ + nested: "original", + }); + expect(Object.getOwnPropertyDescriptor(cloned, "__proto__")?.value).not.toBe( + Object.getOwnPropertyDescriptor(original, "__proto__")?.value, + ); + }); + it("should deep clone a simple object", () => { const original = { a: 1, b: "test", c: true }; const cloned = deepClone(original); @@ -168,7 +238,7 @@ describe("deepClone", () => { }); it("should handle Map objects", () => { - const map = new Map([ + const map = new Map([ ["key1", "value1"], ["key2", { nested: "value2" }], ]); diff --git a/packages/internal/src/utils/objects.ts b/packages/internal/src/utils/objects.ts index bd7a76395..e8e716f96 100644 --- a/packages/internal/src/utils/objects.ts +++ b/packages/internal/src/utils/objects.ts @@ -6,7 +6,7 @@ import { isObject } from "./lang"; * Deep clone an object * * @param obj - The object to clone - * @returns A deep copy of the object (fallback to shallow clone for failures) + * @returns A deep copy of the object, preserving functions by reference in the fallback */ export function deepClone(obj: T): T { try { @@ -17,12 +17,54 @@ export function deepClone(obj: T): T { throw new Error("structuredClone is not available"); } catch (_error) { - // Fallback to shallow clone for primitive types and simple objects - if (obj === null || typeof obj !== "object") { - return obj; + return cloneFallback(obj); + } +} + +/** Clone nested data when structuredClone rejects a value such as a function. */ +function cloneFallback(value: T, seen = new WeakMap()): T { + if (value === null || typeof value !== "object") { + return value; + } + if (seen.has(value)) { + return seen.get(value) as T; + } + + let clone: object; + if (value instanceof Date) { + clone = new Date(value.getTime()); + } else if (value instanceof RegExp) { + clone = new RegExp(value.source, value.flags); + } else if (value instanceof Map) { + clone = new Map(); + } else if (value instanceof Set) { + clone = new Set(); + } else { + clone = Array.isArray(value) + ? new Array(value.length) + : Object.create(Object.getPrototypeOf(value)); + } + seen.set(value, clone); + + if (value instanceof Map && clone instanceof Map) { + for (const [key, nested] of value) { + clone.set(cloneFallback(key, seen), cloneFallback(nested, seen)); + } + } else if (value instanceof Set && clone instanceof Set) { + for (const nested of value) { + clone.add(cloneFallback(nested, seen)); + } + } else { + for (const [key, nested] of Object.entries(value)) { + Object.defineProperty(clone, key, { + value: cloneFallback(nested, seen), + enumerable: true, + configurable: true, + writable: true, + }); } - return { ...obj } as T; } + return clone as T; } /** From a3f41af7e83e11bf57020154e9fb16ce30fe63cd Mon Sep 17 00:00:00 2001 From: liangzhechen655 <186018027+liangzhechen655@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:42:02 +0800 Subject: [PATCH 4/6] fix(internal): retain binary storage and aliases when cloning metadata --- .../memory/adapters/vector/in-memory.spec.ts | 21 +++++++++ packages/internal/src/utils/objects.spec.ts | 45 +++++++++++++++++++ packages/internal/src/utils/objects.ts | 30 +++++++++++-- 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/packages/core/src/memory/adapters/vector/in-memory.spec.ts b/packages/core/src/memory/adapters/vector/in-memory.spec.ts index 86bc7b3fc..7ece43094 100644 --- a/packages/core/src/memory/adapters/vector/in-memory.spec.ts +++ b/packages/core/src/memory/adapters/vector/in-memory.spec.ts @@ -2,6 +2,27 @@ import { describe, expect, it } from "vitest"; import { InMemoryVectorAdapter } from "./in-memory"; describe("InMemoryVectorAdapter search result isolation", () => { + it.each(["store", "get", "search"])( + "preserves and isolates binary metadata at the %s boundary", + async (operation) => { + const adapter = new InMemoryVectorAdapter(); + const bytes = new Uint8Array([1, 2, 3]); + await adapter.store("memory", [1, 0], { fn: () => "test", bytes }); + if (operation === "store") { + bytes[0] = 9; + } else { + const result = + operation === "search" ? (await adapter.search([1, 0]))[0] : await adapter.get("memory"); + const resultBytes = result?.metadata?.bytes as Uint8Array; + expect(resultBytes.byteLength).toBe(3); + resultBytes[0] = 9; + } + const storedBytes = (await adapter.get("memory"))?.metadata?.bytes as Uint8Array; + expect(storedBytes.byteLength).toBe(3); + expect([...storedBytes]).toEqual([1, 2, 3]); + }, + ); + it.each(["search", "get", "store"])( "isolates nested metadata containing functions at the %s boundary", async (operation) => { diff --git a/packages/internal/src/utils/objects.spec.ts b/packages/internal/src/utils/objects.spec.ts index 66f473ac5..9012c8cea 100644 --- a/packages/internal/src/utils/objects.spec.ts +++ b/packages/internal/src/utils/objects.spec.ts @@ -4,6 +4,51 @@ import { deepClone, hasKey } from "./objects"; describe("deepClone", () => { afterEach(() => vi.unstubAllGlobals()); + it("preserves binary data and shared backing buffers in the fallback", () => { + const buffer = new ArrayBuffer(8); + const bytes = new Uint8Array(buffer, 2, 4); + bytes.set([1, 2, 3, 4]); + const view = new DataView(buffer, 2, 4); + const original = { fn: () => "test", buffer, bytes, view, repeated: bytes }; + const cloned = deepClone(original); + expect(cloned.buffer).toBeInstanceOf(ArrayBuffer); + expect(cloned.buffer).not.toBe(buffer); + expect(cloned.bytes).toBeInstanceOf(Uint8Array); + expect(cloned.bytes.byteLength).toBe(4); + expect(cloned.bytes.byteOffset).toBe(2); + expect([...cloned.bytes]).toEqual([1, 2, 3, 4]); + expect(cloned.bytes.buffer).toBe(cloned.buffer); + expect(cloned.view.buffer).toBe(cloned.buffer); + expect(cloned.view.byteOffset).toBe(2); + expect(cloned.view.getUint8(1)).toBe(2); + expect(cloned.repeated).toBe(cloned.bytes); + cloned.bytes[0] = 9; + expect(cloned.view.getUint8(0)).toBe(9); + expect(bytes[0]).toBe(1); + }); + + it("clones shared buffers and their views without sharing the original storage", () => { + const buffer = new SharedArrayBuffer(8); + const bytes = new Uint16Array(buffer, 2, 2); + bytes.set([123, 456]); + const cloned = deepClone({ fn: () => "test", bytes, buffer }); + expect(cloned.buffer).toBeInstanceOf(SharedArrayBuffer); + expect(cloned.buffer).not.toBe(buffer); + expect(cloned.bytes.buffer).toBe(cloned.buffer); + expect(cloned.bytes.byteOffset).toBe(2); + expect([...cloned.bytes]).toEqual([123, 456]); + cloned.bytes[0] = 999; + expect(bytes[0]).toBe(123); + }); + + it("supports environments without SharedArrayBuffer during the fallback", () => { + vi.stubGlobal("SharedArrayBuffer", undefined); + const original = { fn: () => "test", nested: { value: 1 } }; + const cloned = deepClone(original); + expect(cloned.nested).toEqual(original.nested); + expect(cloned.nested).not.toBe(original.nested); + }); + it("isolates nested data when functions prevent structured cloning", () => { const shared = { labels: ["original"] }; const original = { fn: () => "test", first: shared, second: shared }; diff --git a/packages/internal/src/utils/objects.ts b/packages/internal/src/utils/objects.ts index e8e716f96..9fb96f227 100644 --- a/packages/internal/src/utils/objects.ts +++ b/packages/internal/src/utils/objects.ts @@ -31,6 +31,7 @@ function cloneFallback(value: T, seen = new WeakMap()): T { } let clone: object; + let copyProperties = true; if (value instanceof Date) { clone = new Date(value.getTime()); } else if (value instanceof RegExp) { @@ -39,12 +40,35 @@ function cloneFallback(value: T, seen = new WeakMap()): T { clone = new Map(); } else if (value instanceof Set) { clone = new Set(); + } else if ( + value instanceof ArrayBuffer || + (typeof SharedArrayBuffer !== "undefined" && value instanceof SharedArrayBuffer) + ) { + clone = value.slice(0); + copyProperties = false; + } else if (ArrayBuffer.isView(value)) { + const buffer = cloneFallback(value.buffer, seen); + if (value instanceof DataView) { + clone = new DataView(buffer, value.byteOffset, value.byteLength); + } else { + const TypedArray = value.constructor as { + new (buffer: ArrayBufferLike, byteOffset: number, length: number): ArrayBufferView; + readonly BYTES_PER_ELEMENT: number; + }; + clone = new TypedArray( + buffer, + value.byteOffset, + value.byteLength / TypedArray.BYTES_PER_ELEMENT, + ); + } + copyProperties = false; } else { - clone = Array.isArray(value) - ? new Array(value.length) - : Object.create(Object.getPrototypeOf(value)); + clone = Array.isArray(value) ? new Array(value.length) : {}; } seen.set(value, clone); + if (!copyProperties) { + return clone as T; + } if (value instanceof Map && clone instanceof Map) { for (const [key, nested] of value) { From fbcc59b7a79428ab723f7317779921dc2fc5af2b Mon Sep 17 00:00:00 2001 From: liangzhechen655 <186018027+liangzhechen655@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:13:46 +0800 Subject: [PATCH 5/6] fix(internal): preserve binary properties and document metadata compatibility --- .changeset/clean-vector-search-results.md | 4 ++ .../memory/adapters/vector/in-memory.spec.ts | 48 +++++++++++++++++++ .../src/memory/adapters/vector/in-memory.ts | 1 + packages/internal/src/utils/objects.spec.ts | 39 +++++++++++++++ packages/internal/src/utils/objects.ts | 15 +++--- 5 files changed, 100 insertions(+), 7 deletions(-) diff --git a/.changeset/clean-vector-search-results.md b/.changeset/clean-vector-search-results.md index 226b945d4..4b2b58524 100644 --- a/.changeset/clean-vector-search-results.md +++ b/.changeset/clean-vector-search-results.md @@ -4,3 +4,7 @@ --- Copy embeddings in in-memory vector search results and deep-clone metadata at the store, get and search boundaries so caller mutations do not corrupt stored memories or change subsequent user-filtered searches. Only selected search results are cloned. Make the internal deep-clone fallback recursive, preserving cycles and shared references when metadata contains functions. + +Compatibility note: nested custom class instances in vector metadata are now copied as plain data objects, rather than returned by reference. Their prototypes and prototype methods are not retained, so `instanceof` checks against the original class no longer succeed. Store plain data records (for example, the result of an explicit serializer) and reconstruct application-specific classes after retrieval if needed. Functions retain their existing references in the fallback. + +The fallback copies binary storage using the built-in buffer slice methods and preserves own enumerable properties on buffers and views, including cyclic references. diff --git a/packages/core/src/memory/adapters/vector/in-memory.spec.ts b/packages/core/src/memory/adapters/vector/in-memory.spec.ts index 7ece43094..fffd545ff 100644 --- a/packages/core/src/memory/adapters/vector/in-memory.spec.ts +++ b/packages/core/src/memory/adapters/vector/in-memory.spec.ts @@ -2,6 +2,54 @@ import { describe, expect, it } from "vitest"; import { InMemoryVectorAdapter } from "./in-memory"; describe("InMemoryVectorAdapter search result isolation", () => { + it.each(["store", "get", "search"])( + "isolates metadata with an overridden buffer slice at the %s boundary", + async (operation) => { + const adapter = new InMemoryVectorAdapter(); + const buffer = new ArrayBuffer(4); + Object.defineProperty(buffer, "slice", { value: () => buffer, enumerable: true }); + new Uint8Array(buffer)[0] = 1; + await adapter.store("memory", [1, 0], { fn: () => "test", buffer }); + if (operation === "store") { + new Uint8Array(buffer)[0] = 9; + } else { + const result = + operation === "search" ? (await adapter.search([1, 0]))[0] : await adapter.get("memory"); + new Uint8Array(result?.metadata?.buffer as ArrayBuffer)[0] = 9; + } + const stored = (await adapter.get("memory"))?.metadata?.buffer as ArrayBuffer; + expect(new Uint8Array(stored)[0]).toBe(1); + }, + ); + + it.each(["native", "fallback"])( + "copies custom class metadata as plain data using the %s clone path", + async (mode) => { + class CustomMetadata { + labels = ["original"]; + label() { + return this.labels[0]; + } + } + const adapter = new InMemoryVectorAdapter(); + const item = new CustomMetadata(); + await adapter.store("memory", [1, 0], { + item, + fn: mode === "fallback" ? () => "test" : undefined, + }); + item.labels.push("changed"); + const fetched = await adapter.get("memory"); + const [searched] = await adapter.search([1, 0]); + for (const result of [fetched, searched]) { + const copied = result?.metadata?.item as CustomMetadata; + expect(Object.getPrototypeOf(copied)).toBe(Object.prototype); + expect(copied).not.toBeInstanceOf(CustomMetadata); + expect(copied.label).toBeUndefined(); + expect(copied.labels).toEqual(["original"]); + } + }, + ); + it.each(["store", "get", "search"])( "preserves and isolates binary metadata at the %s boundary", async (operation) => { diff --git a/packages/core/src/memory/adapters/vector/in-memory.ts b/packages/core/src/memory/adapters/vector/in-memory.ts index 7a163b820..30848b9c2 100644 --- a/packages/core/src/memory/adapters/vector/in-memory.ts +++ b/packages/core/src/memory/adapters/vector/in-memory.ts @@ -5,6 +5,7 @@ import type { SearchResult, VectorAdapter, VectorItem } from "./types"; /** * Lightweight in-memory vector database adapter * Suitable for development, testing, and small datasets (< 10k vectors) + * Metadata is copied as data; custom class prototypes and methods are not retained. */ export class InMemoryVectorAdapter implements VectorAdapter { private vectors: Map; diff --git a/packages/internal/src/utils/objects.spec.ts b/packages/internal/src/utils/objects.spec.ts index 9012c8cea..c637e1c6a 100644 --- a/packages/internal/src/utils/objects.spec.ts +++ b/packages/internal/src/utils/objects.spec.ts @@ -4,6 +4,45 @@ import { deepClone, hasKey } from "./objects"; describe("deepClone", () => { afterEach(() => vi.unstubAllGlobals()); + it.each([ArrayBuffer, SharedArrayBuffer])( + "ignores an overridden %s slice method in the fallback", + (BufferType) => { + const buffer = new BufferType(4); + Object.defineProperty(buffer, "slice", { value: () => buffer, enumerable: true }); + new Uint8Array(buffer)[0] = 1; + const cloned = deepClone({ fn: () => "test", buffer }); + expect(cloned.buffer).not.toBe(buffer); + new Uint8Array(buffer)[0] = 9; + expect(new Uint8Array(cloned.buffer)[0]).toBe(1); + }, + ); + + it.each([ + new ArrayBuffer(4), + new SharedArrayBuffer(4), + new Uint8Array([1, 2]), + new DataView(new ArrayBuffer(4)), + ])("preserves custom binary properties and cycles in the fallback (%s)", (binary) => { + const value = Object.assign(binary, { tag: { label: "original" }, self: binary }); + const cloned = deepClone({ fn: () => "test", value }); + expect(cloned.value.tag).toEqual(value.tag); + expect(cloned.value.tag).not.toBe(value.tag); + expect(cloned.value.self).toBe(cloned.value); + cloned.value.tag.label = "changed"; + expect(value.tag.label).toBe("original"); + }); + + it("preserves a view referenced by its own backing buffer's custom property", () => { + const bytes = new Uint8Array([1, 2]); + const buffer = Object.assign(bytes.buffer, { view: bytes }); + const cloned = deepClone({ fn: () => "test", bytes, buffer }); + expect(cloned.buffer.view).toBe(cloned.bytes); + expect(cloned.bytes.buffer).toBe(cloned.buffer); + expect([...cloned.bytes]).toEqual([1, 2]); + cloned.bytes[0] = 9; + expect(bytes[0]).toBe(1); + }); + it("preserves binary data and shared backing buffers in the fallback", () => { const buffer = new ArrayBuffer(8); const bytes = new Uint8Array(buffer, 2, 4); diff --git a/packages/internal/src/utils/objects.ts b/packages/internal/src/utils/objects.ts index 9fb96f227..7bb7b5470 100644 --- a/packages/internal/src/utils/objects.ts +++ b/packages/internal/src/utils/objects.ts @@ -31,7 +31,6 @@ function cloneFallback(value: T, seen = new WeakMap()): T { } let clone: object; - let copyProperties = true; if (value instanceof Date) { clone = new Date(value.getTime()); } else if (value instanceof RegExp) { @@ -44,10 +43,16 @@ function cloneFallback(value: T, seen = new WeakMap()): T { value instanceof ArrayBuffer || (typeof SharedArrayBuffer !== "undefined" && value instanceof SharedArrayBuffer) ) { - clone = value.slice(0); - copyProperties = false; + clone = + value instanceof ArrayBuffer + ? ArrayBuffer.prototype.slice.call(value, 0) + : SharedArrayBuffer.prototype.slice.call(value, 0); } else if (ArrayBuffer.isView(value)) { const buffer = cloneFallback(value.buffer, seen); + // Cloning the buffer's own properties may already have cloned this view. + if (seen.has(value)) { + return seen.get(value) as T; + } if (value instanceof DataView) { clone = new DataView(buffer, value.byteOffset, value.byteLength); } else { @@ -61,14 +66,10 @@ function cloneFallback(value: T, seen = new WeakMap()): T { value.byteLength / TypedArray.BYTES_PER_ELEMENT, ); } - copyProperties = false; } else { clone = Array.isArray(value) ? new Array(value.length) : {}; } seen.set(value, clone); - if (!copyProperties) { - return clone as T; - } if (value instanceof Map && clone instanceof Map) { for (const [key, nested] of value) { From d2eb7dc291349c763cd2f99b8681aaf50c96103f Mon Sep 17 00:00:00 2001 From: liangzhechen655 <186018027+liangzhechen655@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:59:18 +0800 Subject: [PATCH 6/6] docs(core): clarify cloned metadata prototype methods --- packages/core/src/memory/adapters/vector/in-memory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/memory/adapters/vector/in-memory.ts b/packages/core/src/memory/adapters/vector/in-memory.ts index 30848b9c2..8b95db5c7 100644 --- a/packages/core/src/memory/adapters/vector/in-memory.ts +++ b/packages/core/src/memory/adapters/vector/in-memory.ts @@ -5,7 +5,7 @@ import type { SearchResult, VectorAdapter, VectorItem } from "./types"; /** * Lightweight in-memory vector database adapter * Suitable for development, testing, and small datasets (< 10k vectors) - * Metadata is copied as data; custom class prototypes and methods are not retained. + * Metadata is copied as data; custom class prototypes and prototype methods are not retained. */ export class InMemoryVectorAdapter implements VectorAdapter { private vectors: Map;