From 69343bf5f2754e4e10ecd2342f81a74d9a8a7cf1 Mon Sep 17 00:00:00 2001 From: Matthew Zhou Date: Thu, 23 Oct 2025 15:52:47 -0700 Subject: [PATCH] feat: Add delete endpoint [LET-4400] (#5711) * Add delete endpoint * Fern autogen --- fern/openapi.json | 55 ++++++++ letta/schemas/enums.py | 1 + letta/server/rest_api/routers/v1/archives.py | 23 +++- letta/services/archive_manager.py | 40 ++++++ letta/validators.py | 1 + tests/managers/test_archive_manager.py | 138 +++++++++++++++++++ 6 files changed, 257 insertions(+), 1 deletion(-) diff --git a/fern/openapi.json b/fern/openapi.json index bdd5a7f1..882617d0 100644 --- a/fern/openapi.json +++ b/fern/openapi.json @@ -613,6 +613,61 @@ } } }, + "/v1/archives/{archive_id}/passages/{passage_id}": { + "delete": { + "tags": ["archives"], + "summary": "Delete Passage From Archive", + "description": "Delete a passage from an archive.\n\nThis permanently removes the passage from both the database and vector storage (if applicable).", + "operationId": "delete_passage_from_archive", + "parameters": [ + { + "name": "archive_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 44, + "maxLength": 44, + "pattern": "^archive-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "description": "The ID of the archive in the format 'archive-'", + "examples": ["archive-123e4567-e89b-42d3-8456-426614174000"], + "title": "Archive Id" + }, + "description": "The ID of the archive in the format 'archive-'" + }, + { + "name": "passage_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "minLength": 44, + "maxLength": 44, + "pattern": "^passage-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "description": "The ID of the passage in the format 'passage-'", + "examples": ["passage-123e4567-e89b-42d3-8456-426614174000"], + "title": "Passage Id" + }, + "description": "The ID of the passage in the format 'passage-'" + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/v1/tools/{tool_id}": { "delete": { "tags": ["tools"], diff --git a/letta/schemas/enums.py b/letta/schemas/enums.py index b7f72457..2ac0db12 100644 --- a/letta/schemas/enums.py +++ b/letta/schemas/enums.py @@ -20,6 +20,7 @@ class PrimitiveType(str, Enum): SOURCE = "source" TOOL = "tool" ARCHIVE = "archive" + PASSAGE = "passage" PROVIDER = "provider" SANDBOX_CONFIG = "sandbox" # Note: sandbox_config IDs use "sandbox" prefix STEP = "step" diff --git a/letta/server/rest_api/routers/v1/archives.py b/letta/server/rest_api/routers/v1/archives.py index ae8f2807..1296a64d 100644 --- a/letta/server/rest_api/routers/v1/archives.py +++ b/letta/server/rest_api/routers/v1/archives.py @@ -8,7 +8,7 @@ from letta.schemas.agent import AgentRelationships from letta.schemas.archive import Archive as PydanticArchive, ArchiveBase from letta.server.rest_api.dependencies import HeaderParams, get_headers, get_letta_server from letta.server.server import SyncServer -from letta.validators import AgentId, ArchiveId +from letta.validators import AgentId, ArchiveId, PassageId router = APIRouter(prefix="/archives", tags=["archives"]) @@ -215,3 +215,24 @@ async def list_agents_for_archive( include=include, ascending=(order == "asc"), ) + + +@router.delete("/{archive_id}/passages/{passage_id}", status_code=204, operation_id="delete_passage_from_archive") +async def delete_passage_from_archive( + archive_id: ArchiveId, + passage_id: PassageId, + server: "SyncServer" = Depends(get_letta_server), + headers: HeaderParams = Depends(get_headers), +): + """ + Delete a passage from an archive. + + This permanently removes the passage from both the database and vector storage (if applicable). + """ + actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id) + await server.archive_manager.delete_passage_from_archive_async( + archive_id=archive_id, + passage_id=passage_id, + actor=actor, + ) + return None diff --git a/letta/services/archive_manager.py b/letta/services/archive_manager.py index 5dff7952..d4c622f9 100644 --- a/letta/services/archive_manager.py +++ b/letta/services/archive_manager.py @@ -257,6 +257,46 @@ class ArchiveManager: await archive_model.hard_delete_async(session, actor=actor) logger.info(f"Deleted archive {archive_id}") + @enforce_types + @trace_method + @raise_on_invalid_id(param_name="archive_id", expected_prefix=PrimitiveType.ARCHIVE) + @raise_on_invalid_id(param_name="passage_id", expected_prefix=PrimitiveType.PASSAGE) + async def delete_passage_from_archive_async( + self, + archive_id: str, + passage_id: str, + actor: PydanticUser = None, + strict_mode: bool = False, + ) -> None: + """Delete a passage from an archive. + + Args: + archive_id: ID of the archive containing the passage + passage_id: ID of the passage to delete + actor: User performing the operation + strict_mode: If True, raise errors on Turbopuffer failures + + Raises: + NoResultFound: If archive or passage not found + ValueError: If passage does not belong to the specified archive + """ + from letta.services.passage_manager import PassageManager + + await self.get_archive_by_id_async(archive_id=archive_id, actor=actor) + + passage_manager = PassageManager() + passage = await passage_manager.get_agent_passage_by_id_async(passage_id=passage_id, actor=actor) + + if passage.archive_id != archive_id: + raise ValueError(f"Passage {passage_id} does not belong to archive {archive_id}") + + await passage_manager.delete_agent_passage_by_id_async( + passage_id=passage_id, + actor=actor, + strict_mode=strict_mode, + ) + logger.info(f"Deleted passage {passage_id} from archive {archive_id}") + @enforce_types @trace_method @raise_on_invalid_id(param_name="agent_id", expected_prefix=PrimitiveType.AGENT) diff --git a/letta/validators.py b/letta/validators.py index 8eb75a2e..42726f5b 100644 --- a/letta/validators.py +++ b/letta/validators.py @@ -57,6 +57,7 @@ GroupId = Annotated[str, PATH_VALIDATORS[PrimitiveType.GROUP.value]()] FileId = Annotated[str, PATH_VALIDATORS[PrimitiveType.FILE.value]()] FolderId = Annotated[str, PATH_VALIDATORS[PrimitiveType.FOLDER.value]()] ArchiveId = Annotated[str, PATH_VALIDATORS[PrimitiveType.ARCHIVE.value]()] +PassageId = Annotated[str, PATH_VALIDATORS[PrimitiveType.PASSAGE.value]()] ProviderId = Annotated[str, PATH_VALIDATORS[PrimitiveType.PROVIDER.value]()] SandboxConfigId = Annotated[str, PATH_VALIDATORS[PrimitiveType.SANDBOX_CONFIG.value]()] StepId = Annotated[str, PATH_VALIDATORS[PrimitiveType.STEP.value]()] diff --git a/tests/managers/test_archive_manager.py b/tests/managers/test_archive_manager.py index eb0a52e5..3a44ec70 100644 --- a/tests/managers/test_archive_manager.py +++ b/tests/managers/test_archive_manager.py @@ -954,3 +954,141 @@ async def test_archive_manager_get_agents_with_include_parameter(server: SyncSer # cleanup await server.agent_manager.delete_agent_async(agent.id, actor=default_user) await server.archive_manager.delete_archive_async(archive.id, actor=default_user) + + +@pytest.mark.asyncio +async def test_archive_manager_delete_passage_from_archive_async(server: SyncServer, default_user): + """Test deleting a passage from an archive.""" + # create archive + archive = await server.archive_manager.create_archive_async( + name="test_passage_deletion_archive", description="Archive for testing passage deletion", actor=default_user + ) + + # create passages + passage1 = await server.passage_manager.create_agent_passage_async( + PydanticPassage( + text="First test passage", + archive_id=archive.id, + organization_id=default_user.organization_id, + embedding=[0.1, 0.2], + embedding_config=DEFAULT_EMBEDDING_CONFIG, + ), + actor=default_user, + ) + + passage2 = await server.passage_manager.create_agent_passage_async( + PydanticPassage( + text="Second test passage", + archive_id=archive.id, + organization_id=default_user.organization_id, + embedding=[0.3, 0.4], + embedding_config=DEFAULT_EMBEDDING_CONFIG, + ), + actor=default_user, + ) + + # verify both passages exist + retrieved_passage1 = await server.passage_manager.get_agent_passage_by_id_async(passage_id=passage1.id, actor=default_user) + assert retrieved_passage1.id == passage1.id + assert retrieved_passage1.archive_id == archive.id + + retrieved_passage2 = await server.passage_manager.get_agent_passage_by_id_async(passage_id=passage2.id, actor=default_user) + assert retrieved_passage2.id == passage2.id + + # delete passage1 from archive + await server.archive_manager.delete_passage_from_archive_async(archive_id=archive.id, passage_id=passage1.id, actor=default_user) + + # verify passage1 is deleted + with pytest.raises(NoResultFound): + await server.passage_manager.get_agent_passage_by_id_async(passage_id=passage1.id, actor=default_user) + + # verify passage2 still exists + retrieved_passage2 = await server.passage_manager.get_agent_passage_by_id_async(passage_id=passage2.id, actor=default_user) + assert retrieved_passage2.id == passage2.id + + # cleanup + await server.passage_manager.delete_agent_passage_by_id_async(passage2.id, actor=default_user) + await server.archive_manager.delete_archive_async(archive.id, actor=default_user) + + +@pytest.mark.asyncio +async def test_archive_manager_delete_passage_from_wrong_archive(server: SyncServer, default_user): + """Test that deleting a passage from the wrong archive raises an error.""" + # create two archives + archive1 = await server.archive_manager.create_archive_async(name="archive_1", actor=default_user) + archive2 = await server.archive_manager.create_archive_async(name="archive_2", actor=default_user) + + # create passage in archive1 + passage = await server.passage_manager.create_agent_passage_async( + PydanticPassage( + text="Passage in archive 1", + archive_id=archive1.id, + organization_id=default_user.organization_id, + embedding=[0.1, 0.2], + embedding_config=DEFAULT_EMBEDDING_CONFIG, + ), + actor=default_user, + ) + + # attempt to delete passage from archive2 (wrong archive) + with pytest.raises(ValueError, match="does not belong to archive"): + await server.archive_manager.delete_passage_from_archive_async(archive_id=archive2.id, passage_id=passage.id, actor=default_user) + + # verify passage still exists + retrieved_passage = await server.passage_manager.get_agent_passage_by_id_async(passage_id=passage.id, actor=default_user) + assert retrieved_passage.id == passage.id + + # cleanup + await server.passage_manager.delete_agent_passage_by_id_async(passage.id, actor=default_user) + await server.archive_manager.delete_archive_async(archive1.id, actor=default_user) + await server.archive_manager.delete_archive_async(archive2.id, actor=default_user) + + +@pytest.mark.asyncio +async def test_archive_manager_delete_nonexistent_passage(server: SyncServer, default_user): + """Test that deleting a non-existent passage raises an error.""" + # create archive + archive = await server.archive_manager.create_archive_async(name="test_nonexistent_passage_archive", actor=default_user) + + # attempt to delete non-existent passage (use valid UUID4 format) + fake_passage_id = f"passage-{uuid.uuid4()}" + with pytest.raises(NoResultFound): + await server.archive_manager.delete_passage_from_archive_async( + archive_id=archive.id, passage_id=fake_passage_id, actor=default_user + ) + + # cleanup + await server.archive_manager.delete_archive_async(archive.id, actor=default_user) + + +@pytest.mark.asyncio +async def test_archive_manager_delete_passage_from_nonexistent_archive(server: SyncServer, default_user): + """Test that deleting a passage from a non-existent archive raises an error.""" + # create archive and passage + archive = await server.archive_manager.create_archive_async(name="temp_archive", actor=default_user) + + passage = await server.passage_manager.create_agent_passage_async( + PydanticPassage( + text="Test passage", + archive_id=archive.id, + organization_id=default_user.organization_id, + embedding=[0.1, 0.2], + embedding_config=DEFAULT_EMBEDDING_CONFIG, + ), + actor=default_user, + ) + + # attempt to delete passage from non-existent archive (use valid UUID4 format) + fake_archive_id = f"archive-{uuid.uuid4()}" + with pytest.raises(NoResultFound): + await server.archive_manager.delete_passage_from_archive_async( + archive_id=fake_archive_id, passage_id=passage.id, actor=default_user + ) + + # verify passage still exists + retrieved_passage = await server.passage_manager.get_agent_passage_by_id_async(passage_id=passage.id, actor=default_user) + assert retrieved_passage.id == passage.id + + # cleanup + await server.passage_manager.delete_agent_passage_by_id_async(passage.id, actor=default_user) + await server.archive_manager.delete_archive_async(archive.id, actor=default_user)