Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
6 changes: 6 additions & 0 deletions .changeset/clean-vector-search-results.md
Original file line number Diff line number Diff line change
@@ -0,0 +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. Make the internal deep-clone fallback recursive, preserving cycles and shared references when metadata contains functions.
111 changes: 111 additions & 0 deletions packages/core/src/memory/adapters/vector/in-memory.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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) => {
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) => {
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" });
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);
});
});
11 changes: 8 additions & 3 deletions packages/core/src/memory/adapters/vector/in-memory.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { deepClone } from "@voltagent/internal/utils";
import { cosineSimilarity } from "../../utils/vector-math";
import type { SearchResult, VectorAdapter, VectorItem } from "./types";

Expand Down Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This used to be { ...metadata }, so nested values came back by reference. Now nested class instances come back as plain objects: structuredClone drops the prototype, and the fallback builds {} at packages/internal/src/utils/objects.ts:66. So instanceof checks and methods on stored metadata stop working. Fine if metadata is meant to be plain data, but it might be worth calling out in the changeset.

});
}

Expand Down Expand Up @@ -83,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<void> {
Expand Down Expand Up @@ -115,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,
};
}

Expand Down
119 changes: 117 additions & 2 deletions packages/internal/src/utils/objects.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,122 @@
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("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 };
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);
Expand Down Expand Up @@ -168,7 +283,7 @@ describe("deepClone", () => {
});

it("should handle Map objects", () => {
const map = new Map([
const map = new Map<string, string | { nested: string }>([
["key1", "value1"],
["key2", { nested: "value2" }],
]);
Expand Down
76 changes: 71 additions & 5 deletions packages/internal/src/utils/objects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(obj: T): T {
try {
Expand All @@ -17,12 +17,78 @@ export function deepClone<T>(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<T>(value: T, seen = new WeakMap<object, unknown>()): T {
if (value === null || typeof value !== "object") {
return value;
}
if (seen.has(value)) {
return seen.get(value) as T;
}

let clone: object;
let copyProperties = true;
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 if (
value instanceof ArrayBuffer ||
(typeof SharedArrayBuffer !== "undefined" && value instanceof SharedArrayBuffer)
) {
clone = value.slice(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
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) : {};
}
seen.set(value, clone);
if (!copyProperties) {
return clone as T;
}
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

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;
}

/**
Expand Down