Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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.
90 changes: 90 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,90 @@
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) => {
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
74 changes: 72 additions & 2 deletions packages/internal/src/utils/objects.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -168,7 +238,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
52 changes: 47 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,54 @@ 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;
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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
}
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;
}

/**
Expand Down