diff --git a/app/mcp_server.py b/app/mcp_server.py index 396a9fa..076e493 100644 --- a/app/mcp_server.py +++ b/app/mcp_server.py @@ -35,12 +35,20 @@ def add_memory(content: str, agent_id: str | None = None, metadata: dict | None @mcp.tool def search_memories( - query: str, limit: int = 10, recency_weight: float = 0.0, mode: str = "semantic" + query: str, limit: int = 15, recency_weight: float = 0.0, mode: str = "semantic" ) -> dict: """Search long-term memory. Searches the single shared memory store for the user, across all agents. + limit (1-100, default 15) is how many memories to return — size it to the + breadth of the query. Use a small limit (~5) for a narrow lookup about one + specific thing, and a larger one (~20-25) for a broad or exploratory + question — a person's overall preferences, everything relevant to a + project — where wider context helps. Stored memories are short atomic + facts, so a larger limit adds little cost; prefer erring high on broad + queries over missing relevant context. + mode: "semantic" (default) ranks by meaning/similarity. Use "keyword" for a case-insensitive substring match when you need an exact term the semantic search may miss — a name, identifier, URL, or rare token. @@ -49,6 +57,8 @@ def search_memories( toward more recently created or updated memories. Leave it at 0 for pure relevance; raise it (e.g. 0.3) when the user asks what is *latest*. """ + if not 1 <= limit <= 100: + raise ValueError(f"limit must be between 1 and 100, got {limit}") if mode not in ("semantic", "keyword"): raise ValueError(f"mode must be 'semantic' or 'keyword', got {mode!r}") if mode == "keyword": diff --git a/app/rest.py b/app/rest.py index aa48bcd..6f2970f 100644 --- a/app/rest.py +++ b/app/rest.py @@ -36,7 +36,11 @@ class SearchRequest(BaseModel): user_id: str | None = None agent_id: str | None = None run_id: str | None = None - limit: int = Field(default=10, ge=1, le=100) + # Default 15 (not the list default of 50): sized so broad/exploratory + # queries get enough context without the caller having to tune it, while a + # narrow lookup can pass a small limit. Atomic-fact memories make a larger + # limit cheap; under-fetching a broad query costs more than over-fetching. + limit: int = Field(default=15, ge=1, le=100) # "semantic" (default, vector similarity) or "keyword" (case-insensitive # substring match for exact terms semantic search misses). mode: Literal["semantic", "keyword"] = "semantic" diff --git a/docs/PRD.md b/docs/PRD.md index e3de042..42af055 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -348,7 +348,7 @@ class SearchRequest(BaseModel): query: str user_id: Optional[str] = None agent_id: Optional[str] = None - limit: int = Field(default=10, ge=1, le=100) + limit: int = Field(default=15, ge=1, le=100) ``` ### 7.3 `/healthz` behavior @@ -398,7 +398,7 @@ def build_mcp() -> FastMCP: def search_memories( query: str, agent_id: str | None = None, - limit: int = 10, + limit: int = 15, ) -> dict: """Search long-term memory by semantic similarity.""" kwargs = {"user_id": default_user, "limit": limit} diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e3eb83f..179fdb4 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -703,6 +703,9 @@ You have a persistent memory store available through the mem0 MCP server. Use it - **At the start of a task**, call `search_memories` with a query about the topic to recall any relevant preferences, decisions, or context before you respond. +- **Size the search to the question.** For a narrow lookup about one specific thing, a small + `limit` (~5) is enough; for a broad or exploratory question — someone's overall preferences, + everything about a project — raise `limit` (~20-25) so you get enough context to work from. - **When the user shares** a durable preference, decision, project convention, or fact they'll likely want recalled later, call `add_memory` to save it. Keep each memory a single clear fact. - **When something changes**, find the existing memory (`search_memories` / `list_memories`) and @@ -739,6 +742,8 @@ at session start. Drop in a tool-agnostic version: A shared long-term memory store is available via the mem0 MCP server. Behavior: 1. Recall: at the start of a task, search memory for context relevant to the request before acting. + Size the search to the question — a small limit (~5) for a specific lookup, a larger one + (~20-25) for a broad or exploratory one. 2. Persist: save durable facts, preferences, decisions, and conventions as they arise. 3. Reconcile: update an existing memory when it changes; avoid near-duplicates. 4. Safety: never store secrets, credentials, or sensitive personal data. @@ -1027,7 +1032,14 @@ curl -X POST https://mem0.your-domain.com/api/v1/memories \ ### Search memories — `POST /api/v1/memories/search` -Semantic search. Optional `agent_id`, `run_id`, `user_id`, and `limit` (1–100, default 10). +Semantic search. Optional `agent_id`, `run_id`, `user_id`, and `limit` (1–100, default 15). + +Size `limit` to the breadth of the query: a small value (~5) for a narrow lookup about one +specific thing, a larger one (~20–25) for a broad or exploratory question where wider context +helps. Stored memories are short atomic facts, so a larger limit costs little — under-fetching a +broad query loses relevant context, while over-fetching a narrow one only appends weaker matches. +The MCP `search_memories` tool takes the same `limit`; its tool description guides connected agents +to size it this way (the server does not resize it for you). ```bash curl -X POST https://mem0.your-domain.com/api/v1/memories/search \ diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 51cfbaa..bfecc92 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -99,7 +99,17 @@ async def test_search_with_recency_weight_invokes_mem(mcp, mem): await client.call_tool("search_memories", {"query": "x", "recency_weight": 0.5}) _, kwargs = mem.search.call_args assert kwargs["filters"] == {"user_id": "default-user"} - assert kwargs["top_k"] == 10 + assert kwargs["top_k"] == 15 + + +async def test_search_default_limit_is_15(mcp, mem): + # Default sized for broad queries; the model narrows it explicitly when + # doing a specific lookup (see the tool docstring). + mem.search.return_value = {"results": []} + async with Client(mcp) as client: + await client.call_tool("search_memories", {"query": "x"}) + _, kwargs = mem.search.call_args + assert kwargs["top_k"] == 15 async def test_list_memories_tool(mcp, mem): @@ -133,6 +143,16 @@ async def test_list_memories_tool_rejects_bad_paging(mcp, mem): mem.get_all.assert_not_called() +async def test_search_memories_tool_rejects_bad_limit(mcp, mem): + # The MCP tool enforces the same 1-100 range the REST API validates via + # pydantic, so an out-of-range limit never reaches the backend. + async with Client(mcp) as client: + for args in ({"query": "x", "limit": 0}, {"query": "x", "limit": 101}): + with pytest.raises(ToolError): + await client.call_tool("search_memories", args) + mem.search.assert_not_called() + + async def test_delete_memory_tool(mcp, mem): async with Client(mcp) as client: await client.call_tool("delete_memory", {"memory_id": "xyz"}) diff --git a/tests/test_rest.py b/tests/test_rest.py index 101bb4f..19705a8 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -75,6 +75,15 @@ def test_search(app_instance, mem, auth_header): assert kwargs["filters"]["user_id"] == "default-user" +def test_search_default_limit_is_15(app_instance, mem, auth_header): + mem.search.return_value = {"results": []} + c = _client(app_instance) + resp = c.post("/api/v1/memories/search", json={"query": "where"}, headers=auth_header) + assert resp.status_code == 200 + _, kwargs = mem.search.call_args + assert kwargs["top_k"] == 15 + + def test_list(app_instance, mem, auth_header): mem.get_all.return_value = {"results": []} c = _client(app_instance)