From 20e064bd8082f9a46a5fb5160473e28d3dd71f30 Mon Sep 17 00:00:00 2001 From: mihhhir08 Date: Wed, 30 Sep 2026 14:03:13 -0400 Subject: [PATCH 1/2] feat(agent-framework-python): 7-tool parity The 7-tool surface (#1430, #1432, #1433) reached the TypeScript tools, the AI SDK and the OpenAI Python SDK, but SupermemoryTools in agent-framework-python still exposed only search_memories, add_memory and get_profile. Add the four missing tools with the same names and model-facing parameters as supermemory_openai: - document_list(limit, page) - document_delete(document_id) - document_add(content, title, description) - memory_forget(memory_id, memory_content, reason) All are scoped to the connection's container_tag. document_delete mirrors the guard in packages/tools: the delete endpoint takes no tag, so the document is fetched first and deletion is refused unless its container-tag set is non-empty and entirely within scope, and unless processing has reached a terminal done/failed state. Fetching first also resolves a custom ID to the real document ID. document_add deliberately does not pass the connection's custom_id, which keys the conversation document and would otherwise be overwritten. Every method used exists in supermemory 3.16.0, the minimum this package supports, so no dependency change is needed. Tests cover scoping, the delete guards, argument validation, the generated tool schemas, and one call through FunctionTool.invoke. They pass in both CI lanes (Python 3.10 + supermemory 3.16.0, Python 3.13 + 3.59.0). --- .../supermemory_agent_framework/__init__.py | 8 + .../src/supermemory_agent_framework/tools.py | 224 ++++++++++++++++++ .../tests/test_tools.py | 212 ++++++++++++++++- 3 files changed, 439 insertions(+), 5 deletions(-) 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" + ) From b74403c31f6e393e5fafd517bc2bff930b7b1de2 Mon Sep 17 00:00:00 2001 From: mihhhir08 Date: Wed, 30 Sep 2026 14:03:13 -0400 Subject: [PATCH 2/2] docs(agent-framework): document the full 7-tool set The integration page said "The agent gets three tools". List all seven and note the container-tag scoping and delete guards, in the docs page and the package README. --- apps/docs/integrations/agent-framework.mdx | 8 +++++++- packages/agent-framework-python/README.md | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) 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