diff --git a/docs/changelog/sdk.mdx b/docs/changelog/sdk.mdx index 80bdb03ac..6fd52f9fe 100644 --- a/docs/changelog/sdk.mdx +++ b/docs/changelog/sdk.mdx @@ -7,6 +7,13 @@ mode: "wide" + + +**Bug Fixes:** +- **Core:** Percent-escape `%`, `&`, and `=` in `user_id`, `agent_id`, and `run_id` when building the session scope key for the recent-conversation buffer, so the key stays unambiguous for ids containing those characters. Ordinary ids keep their existing key; an id already containing `%`, `&`, or `=` maps to a new key, so its buffer starts empty once and refills on the next `add()`. Stored memories are unaffected ([#6892](https://github.com/mem0ai/mem0/pull/6892)) + + + **New Features:** @@ -1196,6 +1203,13 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to- + + +**Bug Fixes:** +- **Core:** Percent-escape `%`, `&`, and `=` in `userId`, `agentId`, and `runId` when building the session scope key for the recent-conversation buffer, so the key stays unambiguous for ids containing those characters. Ordinary ids keep their existing key; an id already containing `%`, `&`, or `=` maps to a new key, so its buffer starts empty once and refills on the next `add()`. Stored memories are unaffected ([#6892](https://github.com/mem0ai/mem0/pull/6892)) + + + **New Features:** diff --git a/mem0-ts/package.json b/mem0-ts/package.json index 6a0540f6e..90ebebec3 100644 --- a/mem0-ts/package.json +++ b/mem0-ts/package.json @@ -1,6 +1,6 @@ { "name": "mem0ai", - "version": "3.1.5", + "version": "3.1.6", "description": "The Memory Layer For Your AI Apps", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index a84543e58..5cef1c256 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -556,11 +556,18 @@ export class Memory { } } + private escapeScopeValue(val: unknown): string { + return String(val) + .replace(/%/g, "%25") + .replace(/&/g, "%26") + .replace(/=/g, "%3D"); + } + private buildSessionScope(filters: SearchFilters): string { const parts: string[] = []; for (const key of ["agent_id", "run_id", "user_id"].sort()) { const val = (filters as any)[key]; - if (val) parts.push(`${key}=${val}`); + if (val) parts.push(`${key}=${this.escapeScopeValue(val)}`); } return parts.join("&"); } diff --git a/mem0-ts/src/oss/tests/session-scope.unit.test.ts b/mem0-ts/src/oss/tests/session-scope.unit.test.ts new file mode 100644 index 000000000..e8ed40303 --- /dev/null +++ b/mem0-ts/src/oss/tests/session-scope.unit.test.ts @@ -0,0 +1,152 @@ +/** + * Unit tests for Memory.buildSessionScope. The recent-conversation buffer key + * derived from user_id/agent_id/run_id must be unique per id combination. + */ +/// +jest.mock("../src/utils/factory", () => { + const { MemoryVectorStore } = jest.requireActual( + "../src/vector_stores/memory", + ); + const { SQLiteManager } = jest.requireActual("../src/storage/SQLiteManager"); + const testEmbedding = new Array(1536).fill(0.1); + class MockEmbedder { + embeddingDims = 1536; + async embed(): Promise { + return testEmbedding; + } + async embedBatch(texts: string[]): Promise { + return texts.map(() => testEmbedding); + } + } + class MockLLM { + async generateResponse() { + return JSON.stringify({ memory: [] }); + } + } + return { + __esModule: true, + EmbedderFactory: { create: jest.fn(() => new MockEmbedder()) }, + LLMFactory: { create: jest.fn(() => new MockLLM()) }, + VectorStoreFactory: { + create: jest.fn( + () => new MemoryVectorStore({ collectionName: "t", dimension: 1536 }), + ), + }, + HistoryManagerFactory: { + create: jest.fn(() => new SQLiteManager(":memory:")), + }, + }; +}); + +import { Memory } from "../src/memory"; +import { SearchFilters } from "../src/types"; + +function scopeOf(memory: Memory, filters: SearchFilters): string { + return (memory as any).buildSessionScope(filters); +} + +function cartesian(values: string[], length: number): string[][] { + if (length === 0) return [[]]; + const rest = cartesian(values, length - 1); + const result: string[][] = []; + for (const value of values) { + for (const tail of rest) { + result.push([value, ...tail]); + } + } + return result; +} + +describe("Memory session scope key", () => { + let memory: Memory; + + beforeAll(async () => { + memory = new Memory(); + await (memory as any)._initPromise; + }); + + it("keeps the unchanged key format for ids without delimiter characters", () => { + expect( + scopeOf(memory, { user_id: "550e8400-e29b-41d4-a716-446655440000" }), + ).toBe("user_id=550e8400-e29b-41d4-a716-446655440000"); + expect(scopeOf(memory, { agent_id: "agent.assistant:v2" })).toBe( + "agent_id=agent.assistant:v2", + ); + expect(scopeOf(memory, { run_id: "12345" })).toBe("run_id=12345"); + expect( + scopeOf(memory, { user_id: "user@example.com", agent_id: "support-bot" }), + ).toBe("agent_id=support-bot&user_id=user@example.com"); + }); + + it("no longer collides an id embedding the join syntax with the equivalent split filters", () => { + const collapsedRun = scopeOf(memory, { run_id: "proj-x&user_id=u1" }); + const splitRun = scopeOf(memory, { user_id: "u1", run_id: "proj-x" }); + expect(collapsedRun).not.toBe(splitRun); + + const collapsedAgent = scopeOf(memory, { run_id: "proj-y&agent_id=a1" }); + const splitAgent = scopeOf(memory, { agent_id: "a1", run_id: "proj-y" }); + expect(collapsedAgent).not.toBe(splitAgent); + }); + + it("maps every distinct filter combination of delimiter-heavy ids to a distinct key", () => { + const keys: string[] = ["user_id", "agent_id", "run_id"]; + const values = [ + "u1", + "r1", + "a1", + "%", + "&", + "=", + "a==", + "a1&run_id=r1", + "a1&user_id=u1", + "r1&user_id=u1", + "a1&run_id=r1&user_id=u1", + ]; + const seen = new Map(); + + for (let mask = 1; mask < 1 << keys.length; mask++) { + const subset = keys.filter((_key, i) => mask & (1 << i)); + for (const combo of cartesian(values, subset.length)) { + const filters: SearchFilters = {}; + subset.forEach((key, i) => (filters[key] = combo[i])); + const scope = scopeOf(memory, filters); + if (seen.has(scope)) { + expect(seen.get(scope)).toEqual(filters); + } else { + seen.set(scope, filters); + } + } + } + }); + + it("gives ids containing delimiter characters a new key format", () => { + expect(scopeOf(memory, { user_id: "dXNlcl9pZDE=" })).toBe( + "user_id=dXNlcl9pZDE%3D", + ); + expect(scopeOf(memory, { agent_id: "x&y" })).toBe("agent_id=x%26y"); + expect(scopeOf(memory, { run_id: "50% off" })).toBe("run_id=50%25 off"); + }); + + it("routes the add pipeline through the builder", async () => { + const db = (memory as any).db; + const spy = jest.spyOn(db, "getLastMessages"); + await memory.add([{ role: "user", content: "hello" }], { + runId: "proj-x&user_id=u1", + }); + expect(spy).toHaveBeenCalledWith("run_id=proj-x%26user_id%3Du1", 10); + spy.mockRestore(); + }); + + it("pins the exact key strings shared with the Python test suite", () => { + expect( + scopeOf(memory, { user_id: "550e8400-e29b-41d4-a716-446655440000" }), + ).toBe("user_id=550e8400-e29b-41d4-a716-446655440000"); + expect( + scopeOf(memory, { user_id: "u1", agent_id: "a1", run_id: "r1" }), + ).toBe("agent_id=a1&run_id=r1&user_id=u1"); + expect(scopeOf(memory, { run_id: "proj-x&user_id=u1" })).toBe( + "run_id=proj-x%26user_id%3Du1", + ); + }); +}); diff --git a/mem0/memory/main.py b/mem0/memory/main.py index 2a7b1b52d..e18dee1d2 100644 --- a/mem0/memory/main.py +++ b/mem0/memory/main.py @@ -404,13 +404,18 @@ def _build_filters_and_metadata( return base_metadata_template, effective_query_filters +def _escape_scope_value(val: Any) -> str: + """Escape the structural delimiters of the session scope key.""" + return str(val).replace("%", "%25").replace("&", "%26").replace("=", "%3D") + + def _build_session_scope(filters): """Build deterministic session scope string from entity IDs.""" parts = [] for key in sorted(["user_id", "agent_id", "run_id"]): val = filters.get(key) if val: - parts.append(f"{key}={val}") + parts.append(f"{key}={_escape_scope_value(val)}") return "&".join(parts) diff --git a/pyproject.toml b/pyproject.toml index 650fe0df3..14428ef00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mem0ai" -version = "2.0.17" +version = "2.0.18" description = "Long-term memory for AI Agents" authors = [ { name = "Mem0", email = "support@mem0.ai" } diff --git a/tests/memory/test_session_scope.py b/tests/memory/test_session_scope.py new file mode 100644 index 000000000..895c85aa6 --- /dev/null +++ b/tests/memory/test_session_scope.py @@ -0,0 +1,131 @@ +import itertools + +import pytest + +from mem0.memory.main import Memory, _build_session_scope, _escape_scope_value + +DELIMITER_VALUES = [ + "u1", + "r1", + "a1", + "%", + "&", + "=", + "%25", + "%26", + "%3D", + "a==", + "a1&run_id=r1", + "a1&user_id=u1", + "r1&user_id=u1", + "a1&run_id=r1&user_id=u1", +] + + +class TestBuildSessionScope: + """Tests that _build_session_scope produces a unique key per id combination.""" + + def test_ordinary_ids_produce_unchanged_scope_keys(self): + """Ids without delimiter characters keep producing the pre-fix key format.""" + cases = [ + ({"user_id": "550e8400-e29b-41d4-a716-446655440000"}, "user_id=550e8400-e29b-41d4-a716-446655440000"), + ({"agent_id": "agent.assistant:v2"}, "agent_id=agent.assistant:v2"), + ({"run_id": "12345"}, "run_id=12345"), + ( + {"user_id": "user@example.com", "agent_id": "support-bot"}, + "agent_id=support-bot&user_id=user@example.com", + ), + ( + {"user_id": "u1", "agent_id": "a1", "run_id": "r1"}, + "agent_id=a1&run_id=r1&user_id=u1", + ), + ] + for filters, expected in cases: + assert _build_session_scope(filters) == expected + + def test_ids_containing_delimiters_do_not_collide(self): + """A value that embeds the join syntax no longer maps to the same key as the equivalent split filters.""" + collapsed_run = {"run_id": "proj-x&user_id=u1"} + split_run = {"user_id": "u1", "run_id": "proj-x"} + assert _build_session_scope(collapsed_run) != _build_session_scope(split_run) + assert _build_session_scope(collapsed_run) == "run_id=proj-x%26user_id%3Du1" + + collapsed_agent = {"run_id": "proj-y&agent_id=a1"} + split_agent = {"agent_id": "a1", "run_id": "proj-y"} + assert _build_session_scope(collapsed_agent) != _build_session_scope(split_agent) + + def test_ids_containing_delimiters_get_a_new_key_format(self): + """Ids holding a delimiter character map to a new key, so their buffer starts empty once after upgrade.""" + assert _build_session_scope({"user_id": "dXNlcl9pZDE="}) == "user_id=dXNlcl9pZDE%3D" + assert _build_session_scope({"agent_id": "x&y"}) == "agent_id=x%26y" + assert _build_session_scope({"run_id": "50% off"}) == "run_id=50%25 off" + + def test_scope_keys_are_unique_per_filter_combination(self): + """Every distinct filter dict built from delimiter-heavy id values maps to a distinct scope key.""" + keys = ["user_id", "agent_id", "run_id"] + seen = {} + for size in range(1, len(keys) + 1): + for key_subset in itertools.combinations(keys, size): + for combo in itertools.product(DELIMITER_VALUES, repeat=size): + filters = dict(zip(key_subset, combo)) + scope = _build_session_scope(filters) + if scope in seen: + assert seen[scope] == filters, f"{seen[scope]} and {filters} both map to {scope!r}" + else: + seen[scope] = filters + + +class TestEscapeScopeValue: + """Tests for the low-level per-value escaping helper.""" + + def test_non_string_input_is_stringified(self): + assert _escape_scope_value(42) == "42" + + def test_percent_is_escaped_before_other_delimiters(self): + assert _escape_scope_value("%26") == "%2526" + assert _escape_scope_value("%26") != "%26" + + def test_each_delimiter_is_escaped(self): + assert _escape_scope_value("%") == "%25" + assert _escape_scope_value("&") == "%26" + assert _escape_scope_value("=") == "%3D" + + +class TestSessionScopeWiring: + """Tests that the add pipeline keys the conversation buffer through the builder.""" + + @pytest.fixture + def memory(self, mocker): + mocker.patch("mem0.memory.main.capture_event") + mock_embedder = mocker.MagicMock() + mock_embedder.return_value.embed.return_value = [0.1, 0.2, 0.3] + mocker.patch("mem0.utils.factory.EmbedderFactory.create", mock_embedder) + mock_vector_store = mocker.MagicMock() + mock_vector_store.return_value.search.return_value = [] + mocker.patch( + "mem0.utils.factory.VectorStoreFactory.create", + side_effect=[mock_vector_store.return_value, mocker.MagicMock()], + ) + mocker.patch("mem0.utils.factory.LlmFactory.create", mocker.MagicMock()) + mocker.patch("mem0.memory.storage.SQLiteManager", mocker.MagicMock()) + + memory = Memory() + memory.config = mocker.MagicMock() + memory.config.custom_instructions = None + memory.custom_instructions = None + memory.api_version = "v1.1" + memory.db.get_last_messages = mocker.MagicMock(return_value=[]) + memory.db.save_messages = mocker.MagicMock() + memory.llm.generate_response.return_value = '{"memory": []}' + return memory + + def test_add_pipeline_uses_the_escaped_key(self, memory): + """The pipeline must route through the builder, not assemble the key inline.""" + memory._add_to_vector_store( + messages=[{"role": "user", "content": "hello"}], + metadata={}, + filters={"run_id": "proj-x&user_id=u1"}, + infer=True, + ) + assert memory.db.get_last_messages.call_args[0][0] == "run_id=proj-x%26user_id%3Du1" + assert memory.db.save_messages.call_args[0][1] == "run_id=proj-x%26user_id%3Du1"