fix(memory): escape delimiters when building the session scope key (#6892)

This commit is contained in:
Kartik
2026-08-11 23:23:55 +05:30
committed by GitHub
parent 35a125585e
commit 71f2ebefa3
7 changed files with 313 additions and 4 deletions
+14
View File
@@ -7,6 +7,13 @@ mode: "wide"
<Tabs>
<Tab title="Python">
<Update label="2026-08-11" description="v2.0.18">
**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))
</Update>
<Update label="2026-08-05" description="v2.0.17">
**New Features:**
@@ -1196,6 +1203,13 @@ See the [OSS v2 to v3 migration guide](https://docs.mem0.ai/migration/oss-v2-to-
<Tab title="TypeScript">
<Update label="2026-08-11" description="v3.1.6">
**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))
</Update>
<Update label="2026-08-05" description="v3.1.5">
**New Features:**
+1 -1
View File
@@ -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",
+8 -1
View File
@@ -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("&");
}
@@ -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.
*/
/// <reference types="jest" />
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<number[]> {
return testEmbedding;
}
async embedBatch(texts: string[]): Promise<number[][]> {
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<string, SearchFilters>();
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",
);
});
});
+6 -1
View File
@@ -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)
+1 -1
View File
@@ -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" }
+131
View File
@@ -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"