fix(memory): escape delimiters when building the session scope key (#6892)
This commit is contained in:
@@ -7,6 +7,13 @@ mode: "wide"
|
|||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab title="Python">
|
<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">
|
<Update label="2026-08-05" description="v2.0.17">
|
||||||
|
|
||||||
**New Features:**
|
**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">
|
<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">
|
<Update label="2026-08-05" description="v3.1.5">
|
||||||
|
|
||||||
**New Features:**
|
**New Features:**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mem0ai",
|
"name": "mem0ai",
|
||||||
"version": "3.1.5",
|
"version": "3.1.6",
|
||||||
"description": "The Memory Layer For Your AI Apps",
|
"description": "The Memory Layer For Your AI Apps",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
|
|||||||
@@ -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 {
|
private buildSessionScope(filters: SearchFilters): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (const key of ["agent_id", "run_id", "user_id"].sort()) {
|
for (const key of ["agent_id", "run_id", "user_id"].sort()) {
|
||||||
const val = (filters as any)[key];
|
const val = (filters as any)[key];
|
||||||
if (val) parts.push(`${key}=${val}`);
|
if (val) parts.push(`${key}=${this.escapeScopeValue(val)}`);
|
||||||
}
|
}
|
||||||
return parts.join("&");
|
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
@@ -404,13 +404,18 @@ def _build_filters_and_metadata(
|
|||||||
return base_metadata_template, effective_query_filters
|
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):
|
def _build_session_scope(filters):
|
||||||
"""Build deterministic session scope string from entity IDs."""
|
"""Build deterministic session scope string from entity IDs."""
|
||||||
parts = []
|
parts = []
|
||||||
for key in sorted(["user_id", "agent_id", "run_id"]):
|
for key in sorted(["user_id", "agent_id", "run_id"]):
|
||||||
val = filters.get(key)
|
val = filters.get(key)
|
||||||
if val:
|
if val:
|
||||||
parts.append(f"{key}={val}")
|
parts.append(f"{key}={_escape_scope_value(val)}")
|
||||||
return "&".join(parts)
|
return "&".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mem0ai"
|
name = "mem0ai"
|
||||||
version = "2.0.17"
|
version = "2.0.18"
|
||||||
description = "Long-term memory for AI Agents"
|
description = "Long-term memory for AI Agents"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Mem0", email = "support@mem0.ai" }
|
{ name = "Mem0", email = "support@mem0.ai" }
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user