fix(mem0-plugin): store assistant-authored summaries with role="assistant" (#6316)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.2.12",
|
||||
"version": "0.2.13",
|
||||
"description": "Persistent memory for Claude Code. Remembers decisions, patterns, and preferences across sessions.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.2.12",
|
||||
"version": "0.2.13",
|
||||
"description": "Persistent memory for Codex. Remembers decisions, patterns, and preferences across sessions.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mem0",
|
||||
"version": "0.2.12",
|
||||
"version": "0.2.13",
|
||||
"description": "Mem0 memory layer for AI applications. Add persistent memory, personalization, and semantic search using the Mem0 Platform MCP server.",
|
||||
"author": {
|
||||
"name": "Mem0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "mem0",
|
||||
"name": "mem0",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"description": "Persistent semantic memory for Antigravity agents. Cross-session, user-level recall via the Mem0 Platform MCP server. 16 slash commands, lifecycle hooks for auto-capture and metadata enforcement.",
|
||||
"author": { "name": "Mem0", "email": "support@mem0.ai" },
|
||||
"publisher": "mem0ai",
|
||||
|
||||
@@ -104,8 +104,11 @@ def store_summary(api_key: str, summary: str, user_id: str, session_id: str, pro
|
||||
}
|
||||
if branch:
|
||||
metadata["branch"] = branch
|
||||
# The compact summary is model-authored prose, in the first person and with no
|
||||
# framing to mark it as such. Under role="user" mem0 reads "I recommend X" as
|
||||
# the human saying it and stores "User recommends X".
|
||||
body = {
|
||||
"messages": [{"role": "user", "content": summary}],
|
||||
"messages": [{"role": "assistant", "content": summary}],
|
||||
"user_id": user_id,
|
||||
"app_id": project_id,
|
||||
"metadata": metadata,
|
||||
|
||||
@@ -175,8 +175,12 @@ def store_summary(
|
||||
if files:
|
||||
metadata["files_touched"] = files[:20]
|
||||
|
||||
# summary_prompt wraps the assistant's own last message. Mem0 extracts "facts
|
||||
# about the user" from each message and role is the only signal telling it who
|
||||
# spoke, so role="user" here turns Claude's opinions into the human's stated
|
||||
# preferences ("User prefers dropping Redis...").
|
||||
body = {
|
||||
"messages": [{"role": "user", "content": summary_prompt}],
|
||||
"messages": [{"role": "assistant", "content": summary_prompt}],
|
||||
"user_id": user_id,
|
||||
"app_id": project_id,
|
||||
"run_id": session_id,
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Regression tests: assistant-authored text must never be posted as role="user".
|
||||
|
||||
The Stop hook (capture_session_summary) and the post-compact hook
|
||||
(capture_compact_summary) both ship *model-authored* prose to
|
||||
POST /v3/memories/add/. Mem0's fact extractor renders each message as
|
||||
"{role}: {content}" and is instructed to extract "facts and preferences about
|
||||
the user" — so role is the only signal separating what the human said from what
|
||||
Claude said.
|
||||
|
||||
Posting Claude's own words under role="user" made the extractor read Claude's
|
||||
first-person prose ("I recommend pgvector", "I found the bug in auth.py") as the
|
||||
*human's* statements and store them under their user_id. The Stop hook fires on
|
||||
every assistant turn, so this corrupted memory on nearly every message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
status = 200
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
|
||||
def _capture(monkeypatch, module):
|
||||
"""Patch urlopen so store_summary posts nowhere; capture the request body."""
|
||||
captured: dict = {}
|
||||
|
||||
def fake_urlopen(req, timeout=0):
|
||||
captured["body"] = json.loads(req.data.decode("utf-8"))
|
||||
return _FakeResp()
|
||||
|
||||
monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen)
|
||||
return captured
|
||||
|
||||
|
||||
# Claude's own voice — first-person prose that must never be attributed to the human.
|
||||
ASSISTANT_PROSE = (
|
||||
"I traced the root cause to auth.py and I recommend we switch to pgvector "
|
||||
"for the vector store. I'll refactor the session handler next."
|
||||
)
|
||||
|
||||
|
||||
def test_session_summary_posts_assistant_prose_as_assistant(monkeypatch):
|
||||
"""Stop hook: the last assistant message must be tagged role="assistant"."""
|
||||
import capture_session_summary as css
|
||||
|
||||
captured = _capture(monkeypatch, css)
|
||||
|
||||
css.store_summary(
|
||||
api_key="test-key",
|
||||
summary_prompt=css.build_summary_prompt(ASSISTANT_PROSE, []),
|
||||
user_id="u1",
|
||||
session_id="s1",
|
||||
project_id="p1",
|
||||
branch="main",
|
||||
files=[],
|
||||
)
|
||||
|
||||
messages = captured["body"]["messages"]
|
||||
for msg in messages:
|
||||
if ASSISTANT_PROSE in msg["content"]:
|
||||
assert msg["role"] == "assistant", (
|
||||
"Claude's own words were posted as role='user' — mem0 will extract "
|
||||
"them as facts about the human. Got role=%r" % msg["role"]
|
||||
)
|
||||
break
|
||||
else:
|
||||
raise AssertionError("assistant prose never made it into the payload")
|
||||
|
||||
|
||||
def test_compact_summary_posts_assistant_prose_as_assistant(monkeypatch):
|
||||
"""Post-compact hook: the compact summary is model-authored, not user-authored."""
|
||||
import capture_compact_summary as ccs
|
||||
|
||||
captured = _capture(monkeypatch, ccs)
|
||||
|
||||
ccs.store_summary(
|
||||
api_key="test-key",
|
||||
summary=ASSISTANT_PROSE,
|
||||
user_id="u1",
|
||||
session_id="s1",
|
||||
project_id="p1",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
messages = captured["body"]["messages"]
|
||||
for msg in messages:
|
||||
if ASSISTANT_PROSE in msg["content"]:
|
||||
assert msg["role"] == "assistant", (
|
||||
"Compact summary (written by Claude) was posted as role='user'. Got role=%r" % msg["role"]
|
||||
)
|
||||
break
|
||||
else:
|
||||
raise AssertionError("assistant prose never made it into the payload")
|
||||
|
||||
|
||||
def test_no_user_role_message_carries_assistant_prose(monkeypatch):
|
||||
"""Belt and braces: no user-role message may contain the assistant's words."""
|
||||
import capture_session_summary as css
|
||||
|
||||
captured = _capture(monkeypatch, css)
|
||||
|
||||
css.store_summary(
|
||||
api_key="test-key",
|
||||
summary_prompt=css.build_summary_prompt(ASSISTANT_PROSE, ["auth.py"]),
|
||||
user_id="u1",
|
||||
session_id="s1",
|
||||
project_id="p1",
|
||||
branch="main",
|
||||
files=["auth.py"],
|
||||
)
|
||||
|
||||
for msg in captured["body"]["messages"]:
|
||||
if msg["role"] == "user":
|
||||
assert ASSISTANT_PROSE not in msg["content"], (
|
||||
"A user-role message carries Claude's prose — this is the misattribution bug."
|
||||
)
|
||||
|
||||
|
||||
def test_auto_capture_preserves_real_roles():
|
||||
"""auto_capture is the reference: it must pass roles through untouched."""
|
||||
import auto_capture
|
||||
|
||||
lines = [
|
||||
json.dumps({"type": "user", "message": {"role": "user", "content": "why is the build failing on main?"}}),
|
||||
json.dumps(
|
||||
{
|
||||
"type": "assistant",
|
||||
"message": {"role": "assistant", "content": [{"type": "text", "text": ASSISTANT_PROSE}]},
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
messages = auto_capture.extract_recent_exchanges(lines)
|
||||
|
||||
assert [m["role"] for m in messages] == ["user", "assistant"]
|
||||
assert ASSISTANT_PROSE in messages[1]["content"]
|
||||
Reference in New Issue
Block a user