diff --git a/apps/docs/integrations/agent-framework.mdx b/apps/docs/integrations/agent-framework.mdx index 2fe191146..b30bf4afb 100644 --- a/apps/docs/integrations/agent-framework.mdx +++ b/apps/docs/integrations/agent-framework.mdx @@ -154,11 +154,17 @@ asyncio.run(main()) ### Available tools -The agent gets three tools: +The agent gets seven tools, the same set as the other Supermemory SDKs: - **`search_memories`** — Search for relevant memories by query - **`add_memory`** — Store new information for later recall - **`get_profile`** — Fetch the user's full profile (static + dynamic facts) +- **`document_list`** — List stored source documents with pagination +- **`document_add`** — Store a longer source (transcript, notes, URL) for automatic memory extraction +- **`document_delete`** — Delete a source document and soft-forget its extracted memories +- **`memory_forget`** — Forget a single learned fact by ID or exact text + +All tools are scoped to the connection's `container_tag`. `document_delete` refuses documents that carry any other tag or are still processing. --- diff --git a/packages/agent-framework-python/README.md b/packages/agent-framework-python/README.md index 3908692d0..202eedcc1 100644 --- a/packages/agent-framework-python/README.md +++ b/packages/agent-framework-python/README.md @@ -256,8 +256,18 @@ agent_tools = tools.get_tools() result = await tools.search_memories("user preferences") result = await tools.add_memory("User prefers dark mode") result = await tools.get_profile() +result = await tools.document_list(limit=20) +result = await tools.document_add("Meeting transcript...", title="Standup") +result = await tools.document_delete("doc_id") +result = await tools.memory_forget(memory_id="mem_id") ``` +`get_tools()` returns seven tools: `search_memories`, `add_memory`, +`get_profile`, `document_list`, `document_delete`, `document_add` and +`memory_forget`. All are scoped to the connection's `container_tag`, and +`document_delete` refuses documents that carry any other tag or are still +processing. + `search_memories` uses v4 hybrid search, so results can contain either a structured memory or a source chunk. The old Python-only `include_full_docs` argument is deprecated and ignored because v4 search does not return full diff --git a/packages/agent-framework-python/src/supermemory_agent_framework/__init__.py b/packages/agent-framework-python/src/supermemory_agent_framework/__init__.py index 10bab4433..ae38337a3 100644 --- a/packages/agent-framework-python/src/supermemory_agent_framework/__init__.py +++ b/packages/agent-framework-python/src/supermemory_agent_framework/__init__.py @@ -9,6 +9,10 @@ MemorySearchResult, MemoryAddResult, ProfileResult, + DocumentListResult, + DocumentDeleteResult, + DocumentAddResult, + MemoryForgetResult, ) from .middleware import ( @@ -43,6 +47,10 @@ "MemorySearchResult", "MemoryAddResult", "ProfileResult", + "DocumentListResult", + "DocumentDeleteResult", + "DocumentAddResult", + "MemoryForgetResult", "SupermemoryChatMiddleware", "SupermemoryMiddlewareOptions", "SupermemoryContextProvider", diff --git a/packages/agent-framework-python/src/supermemory_agent_framework/tools.py b/packages/agent-framework-python/src/supermemory_agent_framework/tools.py index 2b70bdd77..bda5cac1a 100644 --- a/packages/agent-framework-python/src/supermemory_agent_framework/tools.py +++ b/packages/agent-framework-python/src/supermemory_agent_framework/tools.py @@ -38,6 +38,44 @@ class ProfileResult(TypedDict, total=False): error: str | None +class DocumentListResult(TypedDict, total=False): + """Result type for document list operations.""" + + success: bool + documents: list[Any] | None + pagination: dict[str, Any] | None + error: str | None + + +class DocumentDeleteResult(TypedDict, total=False): + """Result type for document delete operations.""" + + success: bool + message: str | None + error: str | None + + +class DocumentAddResult(TypedDict, total=False): + """Result type for document add operations.""" + + success: bool + document: Any | None + error: str | None + + +class MemoryForgetResult(TypedDict, total=False): + """Result type for memory forget operations.""" + + success: bool + message: str | None + error: str | None + + +# Deleting a document mid-ingestion races the extraction pipeline, so only +# documents that reached a terminal state are eligible. +_TERMINAL_DOCUMENT_STATUSES = {"done", "failed"} + + def _to_jsonable(value: Any) -> Any: """Convert generated SDK models into JSON-compatible structures.""" if isinstance(value, dict): @@ -176,6 +214,151 @@ async def get_profile( result = {"success": False, "error": str(error)} return json.dumps(result) + async def document_list( + self, + limit: Annotated[int, "Maximum number of documents to return"] = 10, + page: Annotated[int, "Page number to fetch, 1-based"] = 1, + ) -> str: + """List stored source documents in the configured container tag.""" + try: + response = await self._client.documents.list( + container_tags=[self._connection.container_tag], + limit=limit, + page=page, + ) + result: DocumentListResult = { + "success": True, + "documents": [_to_jsonable(item) for item in response.memories or []], + "pagination": _to_jsonable(response.pagination), + } + return json.dumps(result, default=str) + except Exception as error: + result = {"success": False, "error": str(error)} + return json.dumps(result) + + async def document_delete( + self, + document_id: Annotated[ + str, + "Document ID from document_list. Permanently deletes the source and " + "soft-forgets its extracted memories. Not a profile memory ID.", + ], + ) -> str: + """Delete a stored source document if it is safely within scope.""" + try: + # The delete endpoint takes no container tag, so verify scope on the + # document itself first. This also resolves a custom ID to its real ID. + document = await self._client.documents.get(document_id) + tags = document.container_tags or [] + if not tags or any(tag != self._connection.container_tag for tag in tags): + result: DocumentDeleteResult = { + "success": False, + "error": "Document is outside the configured container tag", + } + return json.dumps(result) + + status = getattr(document, "status", None) + if status is not None and status not in _TERMINAL_DOCUMENT_STATUSES: + result = { + "success": False, + "error": ( + "Document cannot be deleted while it is still processing " + f"(status: {status})" + ), + } + return json.dumps(result) + + await self._client.documents.delete(document.id) + result = { + "success": True, + "message": f"Document {document_id} deleted successfully", + } + return json.dumps(result) + except Exception as error: + result = {"success": False, "error": str(error)} + return json.dumps(result) + + async def document_add( + self, + content: Annotated[ + str, + "Document body to store: plain text, a conversation transcript, a long " + "pasted blob, or a URL. Memories are extracted automatically; do not " + "split into add_memory calls.", + ], + title: Annotated[Optional[str], "Optional title for the document"] = None, + description: Annotated[ + Optional[str], "Optional description for the document" + ] = None, + ) -> str: + """Store a source document for background processing and memory extraction.""" + try: + metadata: dict[str, str] = {} + if title: + metadata["title"] = title + if description: + metadata["description"] = description + + # No custom_id: the connection's custom_id keys the conversation + # document, and reusing it here would overwrite that conversation. + kwargs: dict[str, Any] = { + "content": content, + "container_tag": self._connection.container_tag, + } + if metadata: + kwargs["metadata"] = metadata + + response = await self._client.documents.add(**kwargs) + result: DocumentAddResult = { + "success": True, + "document": _to_jsonable(response), + } + return json.dumps(result, default=str) + except Exception as error: + result = {"success": False, "error": str(error)} + return json.dumps(result) + + async def memory_forget( + self, + memory_id: Annotated[ + Optional[str], + "Memory entry ID from a search_memories result containing a memory. " + "Chunk and document IDs are invalid.", + ] = None, + memory_content: Annotated[ + Optional[str], + "Exact text of the profile memory to forget (alternative to memory_id). " + "If unsure, search first and use memory_id.", + ] = None, + reason: Annotated[ + Optional[str], + "Optional reason recorded when forgetting (e.g. outdated, user correction)", + ] = None, + ) -> str: + """Soft-delete a single extracted profile memory.""" + if not memory_id and not memory_content: + result: MemoryForgetResult = { + "success": False, + "error": "Either memory_id or memory_content must be provided", + } + return json.dumps(result) + + try: + kwargs: dict[str, Any] = {"container_tag": self._connection.container_tag} + if memory_id: + kwargs["id"] = memory_id + if memory_content: + kwargs["content"] = memory_content + if reason: + kwargs["reason"] = reason + + await self._client.memories.forget(**kwargs) + result = {"success": True, "message": "Memory forgotten successfully"} + return json.dumps(result) + except Exception as error: + result = {"success": False, "error": str(error)} + return json.dumps(result) + def get_tools(self) -> list[FunctionTool]: """Get all Supermemory tools as FunctionTool instances. @@ -207,6 +390,47 @@ def get_tools(self) -> list[FunctionTool]: "by providing a query." ), )(self.get_profile), + tool( + name="document_list", + description=( + "List stored source documents (conversations, URLs, files, pasted " + "text) with pagination. Returns document metadata and summaries, " + "including IDs for document_delete. It does not return full source " + "content or memory IDs." + ), + )(self.document_list), + tool( + name="document_delete", + description=( + "Permanently delete a stored source document and soft-forget " + "memories extracted from it. Use a document ID from document_list " + "when the user wants to remove an entire source. Deletion is " + "refused for documents outside the configured scope, shared with " + "another scope, or still processing. Use memory_forget to remove " + "one learned fact." + ), + )(self.document_delete), + tool( + name="document_add", + description=( + "Store a source document for asynchronous processing and automatic " + "memory extraction. Use when the user gives you raw content to " + "ingest (a pasted text blob, conversation transcript, notes, URL, " + "or article) rather than a single atomic fact; use add_memory for " + "one short generalizable sentence. Memories are extracted in the " + "background, so do not call add_memory for facts inside the document." + ), + )(self.document_add), + tool( + name="memory_forget", + description=( + "Soft-delete a single extracted profile memory (a learned fact) so " + "it no longer appears in profile or search. This does not delete " + "source documents. Provide a memory_id from a search result " + "containing a memory, or memory_content for an exact text match. " + "Use document_delete to remove an entire source." + ), + )(self.memory_forget), ] async def _search_memories_tool( diff --git a/packages/agent-framework-python/tests/test_tools.py b/packages/agent-framework-python/tests/test_tools.py index 00da0f7d8..4d4749736 100644 --- a/packages/agent-framework-python/tests/test_tools.py +++ b/packages/agent-framework-python/tests/test_tools.py @@ -1,9 +1,23 @@ """Tests for Supermemory tools.""" +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock + import pytest from supermemory_agent_framework import AgentSupermemory, SupermemoryTools +ALL_TOOL_NAMES = [ + "search_memories", + "add_memory", + "get_profile", + "document_list", + "document_delete", + "document_add", + "memory_forget", +] + def _make_conn(**kwargs): kwargs.setdefault("api_key", "test-key") @@ -27,16 +41,13 @@ def test_get_tools_returns_list(self) -> None: tools = SupermemoryTools(conn) result = tools.get_tools() assert isinstance(result, list) - assert len(result) == 3 + assert len(result) == 7 def test_get_tools_names(self) -> None: conn = _make_conn() tools = SupermemoryTools(conn) result = tools.get_tools() - names = [t.name for t in result] - assert "search_memories" in names - assert "add_memory" in names - assert "get_profile" in names + assert [t.name for t in result] == ALL_TOOL_NAMES def test_uses_connection_client(self) -> None: conn = _make_conn() @@ -47,3 +58,194 @@ def test_shares_custom_id_with_connection(self) -> None: conn = _make_conn(conversation_id="conv-123") tools = SupermemoryTools(conn) assert tools._connection.custom_id == "conversation_conv-123" + + +def _make_tools(container_tag: str = "user-1") -> SupermemoryTools: + tools = SupermemoryTools(_make_conn(container_tag=container_tag)) + tools._client = AsyncMock() + return tools + + +def _document(**fields): + fields.setdefault("id", "doc_1") + fields.setdefault("container_tags", ["user-1"]) + fields.setdefault("status", "done") + return SimpleNamespace(**fields) + + +class TestToolSchemas: + @pytest.mark.parametrize( + ("name", "required", "properties"), + [ + ("document_list", [], ["limit", "page"]), + ("document_delete", ["document_id"], ["document_id"]), + ("document_add", ["content"], ["content", "title", "description"]), + ("memory_forget", [], ["memory_id", "memory_content", "reason"]), + ], + ) + def test_model_facing_parameters(self, name, required, properties) -> None: + tool = {t.name: t for t in _make_tools().get_tools()}[name] + schema = tool.parameters() + + assert schema.get("required", []) == required + assert list(schema["properties"]) == properties + + +class TestDocumentList: + async def test_lists_only_the_configured_tag(self) -> None: + tools = _make_tools() + tools._client.documents.list.return_value = SimpleNamespace( + memories=[{"id": "doc_1"}], + pagination={"currentPage": 2, "totalPages": 3}, + ) + + result = json.loads(await tools.document_list(limit=5, page=2)) + + tools._client.documents.list.assert_awaited_once_with( + container_tags=["user-1"], limit=5, page=2 + ) + assert result == { + "success": True, + "documents": [{"id": "doc_1"}], + "pagination": {"currentPage": 2, "totalPages": 3}, + } + + async def test_reports_api_errors(self) -> None: + tools = _make_tools() + tools._client.documents.list.side_effect = RuntimeError("boom") + + result = json.loads(await tools.document_list()) + + assert result == {"success": False, "error": "boom"} + + +class TestDocumentDelete: + async def test_deletes_by_resolved_id(self) -> None: + tools = _make_tools() + tools._client.documents.get.return_value = _document(id="doc_real") + + result = json.loads(await tools.document_delete("my-custom-id")) + + tools._client.documents.get.assert_awaited_once_with("my-custom-id") + tools._client.documents.delete.assert_awaited_once_with("doc_real") + assert result["success"] is True + + @pytest.mark.parametrize( + "container_tags", + [["other-user"], ["user-1", "other-user"], [], None], + ids=["foreign", "shared", "empty", "missing"], + ) + async def test_refuses_documents_outside_scope(self, container_tags) -> None: + tools = _make_tools() + tools._client.documents.get.return_value = _document( + container_tags=container_tags + ) + + result = json.loads(await tools.document_delete("doc_1")) + + assert result["success"] is False + assert "outside" in result["error"] + tools._client.documents.delete.assert_not_awaited() + + async def test_refuses_documents_still_processing(self) -> None: + tools = _make_tools() + tools._client.documents.get.return_value = _document(status="extracting") + + result = json.loads(await tools.document_delete("doc_1")) + + assert result["success"] is False + assert "extracting" in result["error"] + tools._client.documents.delete.assert_not_awaited() + + async def test_deletes_failed_documents(self) -> None: + tools = _make_tools() + tools._client.documents.get.return_value = _document(status="failed") + + result = json.loads(await tools.document_delete("doc_1")) + + assert result["success"] is True + + async def test_reports_missing_documents(self) -> None: + tools = _make_tools() + tools._client.documents.get.side_effect = RuntimeError("404 Not Found") + + result = json.loads(await tools.document_delete("doc_missing")) + + assert result == {"success": False, "error": "404 Not Found"} + tools._client.documents.delete.assert_not_awaited() + + +class TestDocumentAdd: + async def test_adds_to_the_configured_tag_with_metadata(self) -> None: + tools = _make_tools() + tools._client.documents.add.return_value = {"id": "doc_1", "status": "queued"} + + result = json.loads( + await tools.document_add( + "meeting notes", title="Standup", description="Sep 30" + ) + ) + + tools._client.documents.add.assert_awaited_once_with( + content="meeting notes", + container_tag="user-1", + metadata={"title": "Standup", "description": "Sep 30"}, + ) + assert result == { + "success": True, + "document": {"id": "doc_1", "status": "queued"}, + } + + async def test_does_not_reuse_the_conversation_custom_id(self) -> None: + tools = SupermemoryTools( + _make_conn(container_tag="user-1", conversation_id="conv-1") + ) + tools._client = AsyncMock() + tools._client.documents.add.return_value = {"id": "doc_1"} + + await tools.document_add("an article") + + kwargs = tools._client.documents.add.await_args.kwargs + assert "custom_id" not in kwargs + assert "metadata" not in kwargs + + +class TestMemoryForget: + async def test_forgets_by_id_in_the_configured_tag(self) -> None: + tools = _make_tools() + + result = json.loads( + await tools.memory_forget(memory_id="mem_1", reason="outdated") + ) + + tools._client.memories.forget.assert_awaited_once_with( + container_tag="user-1", id="mem_1", reason="outdated" + ) + assert result["success"] is True + + async def test_forgets_by_exact_content(self) -> None: + tools = _make_tools() + + await tools.memory_forget(memory_content="likes tea") + + tools._client.memories.forget.assert_awaited_once_with( + container_tag="user-1", content="likes tea" + ) + + async def test_requires_an_id_or_content(self) -> None: + tools = _make_tools() + + result = json.loads(await tools.memory_forget()) + + assert result["success"] is False + tools._client.memories.forget.assert_not_awaited() + + async def test_is_callable_through_the_framework(self) -> None: + tools = _make_tools() + tool = {t.name: t for t in tools.get_tools()}["memory_forget"] + + await tool.invoke(arguments={"memory_id": "mem_1"}) + + tools._client.memories.forget.assert_awaited_once_with( + container_tag="user-1", id="mem_1" + )