Spaces:
Paused
Paused
| # -*- coding: utf-8 -*- | |
| """Mem0 middleware demo (open-source mem0, AgentScope-driven). | |
| Drives two independent agent sessions for the same ``user_id`` so | |
| mem0's cross-session memory effect is visible. Each turn streams | |
| events from ``agent.reply_stream`` and prints the ones that matter: | |
| - ``[mem0 β context (static)]`` β the memory note the middleware | |
| appended to ``state.context`` (fires at ``ReplyStartEvent``, before | |
| any tool call, so the output order matches the data flow) | |
| - ``[tool call (agent)]`` β each ``search_memory`` / ``add_memory`` | |
| invocation the agent makes on its own | |
| - ``[assistant]`` β the assistant's reply, concatenated from the | |
| ``TextBlockDeltaEvent`` stream | |
| - ``[context β mem0 (static)]`` β facts the middleware wrote back | |
| after the turn | |
| The mode tag (``static`` / ``agent``) on each line tells you which | |
| control path produced it. | |
| Starts each run from a clean mem0 store (for ``user_id``) so the | |
| demo is reproducible. | |
| Requires: | |
| pip install agentscope mem0ai | |
| export DASHSCOPE_API_KEY=sk-... | |
| """ | |
| import asyncio | |
| import logging | |
| import os | |
| import shutil | |
| from mem0 import AsyncMemory | |
| from mem0.configs.base import MemoryConfig | |
| from mem0.vector_stores.configs import VectorStoreConfig | |
| from agentscope.agent import Agent | |
| from agentscope.credential import DashScopeCredential | |
| from agentscope.embedding import DashScopeEmbeddingModel | |
| from agentscope.event import ( | |
| ReplyStartEvent, | |
| TextBlockDeltaEvent, | |
| ToolCallDeltaEvent, | |
| ToolCallStartEvent, | |
| ToolResultEndEvent, | |
| ToolResultTextDeltaEvent, | |
| ) | |
| from agentscope.message import UserMsg | |
| from agentscope.middleware import Mem0Middleware | |
| from agentscope.model import DashScopeChatModel | |
| from agentscope.tool import Toolkit | |
| MODE = "both" # try "static_control" or "agent_control" too | |
| # Silence mem0's noisy init warnings (qdrant migration notice, spaCy | |
| # missing, fastembed missing) β they're informational, not actionable. | |
| logging.getLogger("mem0").setLevel(logging.ERROR) | |
| async def _facts_in_mem0(client: AsyncMemory, user_id: str) -> list[str]: | |
| """Return mem0's current facts for ``user_id`` as plain strings.""" | |
| res = await client.get_all(filters={"user_id": user_id}) | |
| items = res.get("results", res) if isinstance(res, dict) else res | |
| out: list[str] = [] | |
| for m in items: | |
| if isinstance(m, dict): | |
| text = m.get("memory") | |
| if isinstance(text, str) and text: | |
| out.append(text) | |
| return out | |
| def _injected_memory_bullets(agent: Agent) -> list[str]: | |
| """Extract the bullet lines from the memory note the middleware | |
| appended to ``agent.state.context`` (if any) β strips the section | |
| header and intro so we see only the actual retrieved facts.""" | |
| for msg in agent.state.context: | |
| if getattr(msg, "name", None) != "memory": | |
| continue | |
| hint_text = "\n".join( | |
| block.hint for block in msg.get_content_blocks("hint") | |
| ) | |
| return [ | |
| line[2:].strip() | |
| for line in hint_text.splitlines() | |
| if line.startswith("- ") | |
| ] | |
| return [] | |
| async def _run_turn(agent: Agent, user_msg: UserMsg) -> str: | |
| """Drive one reply turn through ``agent.reply_stream`` and print | |
| each middleware contribution as it happens, in the order it | |
| happens: | |
| 1. ``ReplyStartEvent`` fires right after the agent ingests the new | |
| user input AND the middleware has appended any retrieved memory | |
| note to ``state.context`` (static path) β so this is the right | |
| place to surface ``[mem0 β context]``. | |
| 2. ``ToolCallStartEvent`` / ``ToolResultEndEvent`` bracket each | |
| ``search_memory`` / ``add_memory`` invocation the agent makes | |
| on its own (agent path). | |
| 3. ``TextBlockDeltaEvent`` carries the assistant's streamed reply | |
| text β concatenating every delta yields the final message | |
| content (there is no separate "final Msg" reply_stream | |
| withholds). | |
| Each printed line is tagged with the mem0 control path that | |
| produced it (``static`` vs ``agent``) so the demo stays readable | |
| in any of the three modes. | |
| """ | |
| pending_args: dict[str, str] = {} | |
| pending_names: dict[str, str] = {} | |
| pending_results: dict[str, str] = {} | |
| text_parts: list[str] = [] | |
| memory_announced = False | |
| async for ev in agent.reply_stream(inputs=user_msg): | |
| if isinstance(ev, ReplyStartEvent) and not memory_announced: | |
| injected = _injected_memory_bullets(agent) | |
| print( | |
| f"[mem0 β context (static)] retrieved " | |
| f"{len(injected)} memory note(s):", | |
| ) | |
| for b in injected: | |
| print(f" β {b}") | |
| memory_announced = True | |
| elif isinstance(ev, ToolCallStartEvent): | |
| pending_names[ev.tool_call_id] = ev.tool_call_name | |
| pending_args[ev.tool_call_id] = "" | |
| pending_results[ev.tool_call_id] = "" | |
| elif isinstance(ev, ToolCallDeltaEvent): | |
| pending_args[ev.tool_call_id] += ev.delta | |
| elif isinstance(ev, ToolResultTextDeltaEvent): | |
| pending_results[ev.tool_call_id] += ev.delta | |
| elif isinstance(ev, ToolResultEndEvent): | |
| name = pending_names.pop(ev.tool_call_id, "<unknown>") | |
| args = pending_args.pop(ev.tool_call_id, "") | |
| result = pending_results.pop(ev.tool_call_id, "") | |
| # ev.state is a StrEnum at the type level but pydantic | |
| # may have deserialized it to a plain str; f-string both | |
| # cases yields the same value string. | |
| print(f"[tool call (agent)] {name}({args}) β state={ev.state}") | |
| for line in result.splitlines() or [""]: | |
| if line: | |
| print(f" β {line}") | |
| elif isinstance(ev, TextBlockDeltaEvent): | |
| text_parts.append(ev.delta) | |
| return "".join(text_parts) | |
| async def main() -> None: | |
| """Drive two cross-session agent turns and print middleware effects.""" | |
| api_key = os.environ["DASHSCOPE_API_KEY"] | |
| user_id = "alice" | |
| # Wipe mem0's local files BEFORE constructing the client so each | |
| # demo run starts clean. We don't use ``mem0_client.delete_all()`` | |
| # because qdrant-client's local SQLite layer has a race under | |
| # mem0's parallel ``asyncio.gather`` deletes (sqlite3.InterfaceError). | |
| qdrant_path = "/tmp/qdrant" # matches the vector_store config below | |
| history_db = os.path.expanduser("~/.mem0/history.db") | |
| print("=== resetting mem0 local state ===") | |
| print(f" rm -rf {qdrant_path}") | |
| shutil.rmtree(qdrant_path, ignore_errors=True) | |
| print(f" rm -f {history_db}") | |
| try: | |
| os.remove(history_db) | |
| except FileNotFoundError: | |
| pass | |
| # `stream` can be True or False β the AgentScopeβmem0 adapter | |
| # drains an async generator and uses the last chunk (which carries | |
| # the full accumulated content per AgentScope's streaming contract). | |
| # The agent's own `reply_stream` still emits per-delta events | |
| # regardless of this setting, so streaming-mode does not change the | |
| # demo's printed output shape. | |
| chat_model = DashScopeChatModel( | |
| credential=DashScopeCredential(api_key=api_key), | |
| model="qwen3.7-max", | |
| stream=False, | |
| ) | |
| embedding_model = DashScopeEmbeddingModel( | |
| credential=DashScopeCredential(api_key=api_key), | |
| model="text-embedding-v4", | |
| dimensions=1536, # matches mem0's Qdrant default | |
| ) | |
| # Explicit vector-store config (here we just spell out mem0's | |
| # default local Qdrant β collection ``mem0``, on-disk at | |
| # ``/tmp/qdrant``, 1536-d vectors). Override any of these for | |
| # remote Qdrant, alternate collection names, etc. Pass the | |
| # ``MemoryConfig`` through ``mem0_config=`` and the middleware | |
| # keeps everything you set, only swapping ``.llm`` and | |
| # ``.embedder`` to route through your AgentScope models. | |
| # | |
| # Recommended for Windows users and any production setup: run | |
| # Qdrant in Docker and connect over the network instead of using | |
| # the local on-disk backend. Local on-disk Qdrant takes an | |
| # exclusive file lock that's brittle on Windows (different | |
| # filesystem-lock semantics) and breaks under concurrent agent | |
| # instances. To switch: | |
| # | |
| # docker run -p 6333:6333 -p 6334:6334 \\ | |
| # -v $(pwd)/qdrant_storage:/qdrant/storage \\ | |
| # qdrant/qdrant | |
| # | |
| # vector_store=VectorStoreConfig( | |
| # provider="qdrant", | |
| # config={ | |
| # "collection_name": "mem0", | |
| # "host": "localhost", # Docker container | |
| # "port": 6333, | |
| # "embedding_model_dims": 1536, | |
| # }, | |
| # ) | |
| # | |
| # (You'd also drop the ``shutil.rmtree(qdrant_path)`` wipe above β | |
| # state lives in the Docker volume, not the local filesystem.) | |
| mem0_cfg = MemoryConfig( | |
| vector_store=VectorStoreConfig( | |
| provider="qdrant", | |
| config={ | |
| "collection_name": "mem0", | |
| "path": qdrant_path, | |
| "embedding_model_dims": 1536, | |
| "on_disk": False, | |
| }, | |
| ), | |
| ) | |
| mw = Mem0Middleware( | |
| user_id=user_id, | |
| agent_id="datascope_assistant", | |
| chat_model=chat_model, | |
| embedding_model=embedding_model, | |
| mem0_config=mem0_cfg, | |
| mode=MODE, | |
| top_k=5, | |
| ) | |
| # For the hosted mem0 Platform, swap the construction above for: | |
| # | |
| # from mem0 import AsyncMemoryClient | |
| # mw = Mem0Middleware( | |
| # user_id=user_id, | |
| # client=AsyncMemoryClient(api_key=os.environ["MEM0_API_KEY"]), | |
| # mode=MODE, | |
| # ) | |
| # | |
| # No local Qdrant / vector_store config needed β extraction and | |
| # storage all happen in mem0's cloud service. | |
| # pylint: disable-next=protected-access | |
| mem0_client: AsyncMemory = mw._client # demo only β peek at the | |
| # constructed client to inspect mem0 state between turns. | |
| # ================================================================= | |
| # SESSION 1 | |
| # ================================================================= | |
| print(f"\n=== SESSION 1 (mode={MODE!r}) ===") | |
| user_msg_1 = ( | |
| "Hi! For any chart, please default to dark mode and use " | |
| "matplotlib. Also I'm based in Hangzhou." | |
| ) | |
| print(f"\n[user] {user_msg_1}\n") | |
| before = await _facts_in_mem0(mem0_client, user_id) | |
| agent = Agent( | |
| name="datascope_assistant", | |
| system_prompt=( | |
| "You are a helpful data-analysis assistant. Be concise. " | |
| "If you learn a durable user preference, save it with " | |
| "the add_memory tool when one is available." | |
| ), | |
| model=chat_model, | |
| toolkit=Toolkit(tools=await mw.list_tools()), | |
| middlewares=[mw], | |
| ) | |
| reply_text = await _run_turn(agent, UserMsg("alice", user_msg_1)) | |
| print(f"\n[assistant] {reply_text}") | |
| after = await _facts_in_mem0(mem0_client, user_id) | |
| new = [f for f in after if f not in before] | |
| print(f"\n[context β mem0 (static)] extracted {len(new)} new fact(s):") | |
| for f in new: | |
| print(f" + {f}") | |
| # ================================================================= | |
| # SESSION 2 β fresh Agent, empty chat context. mem0 should bridge. | |
| # ================================================================= | |
| print(f"\n=== SESSION 2 (fresh agent, mem0 bridges; mode={MODE!r}) ===") | |
| user_msg_2 = ( | |
| "Plot me a bar chart of monthly sales β pick reasonable " | |
| "defaults for theme and library." | |
| ) | |
| print(f"\n[user] {user_msg_2}\n") | |
| before = await _facts_in_mem0(mem0_client, user_id) | |
| agent = Agent( | |
| name="datascope_assistant", | |
| system_prompt=( | |
| "You are a helpful data-analysis assistant. Be concise. " | |
| "If you learn a durable user preference, save it with " | |
| "the add_memory tool when one is available." | |
| ), | |
| model=chat_model, | |
| toolkit=Toolkit(tools=await mw.list_tools()), | |
| middlewares=[mw], | |
| ) | |
| reply_text = await _run_turn(agent, UserMsg("alice", user_msg_2)) | |
| print(f"\n[assistant] {reply_text}") | |
| after = await _facts_in_mem0(mem0_client, user_id) | |
| new = [f for f in after if f not in before] | |
| print(f"\n[context β mem0 (static)] extracted {len(new)} new fact(s):") | |
| for f in new: | |
| print(f" + {f}") | |
| if __name__ == "__main__": | |
| asyncio.run(main()) | |