From 757bbcac374abf16e5fc8b6105a338ddd01c02ce Mon Sep 17 00:00:00 2001 From: cthomas Date: Mon, 6 Oct 2025 15:24:02 -0700 Subject: [PATCH] feat: add pagination to agent files endpoint (#5086) * feat: add pagination to agent files endpoint --- fern/openapi.json | 101 +++++++++++++++++---- letta/server/rest_api/routers/v1/agents.py | 27 +++++- letta/services/files_agents_manager.py | 29 +++++- 3 files changed, 133 insertions(+), 24 deletions(-) diff --git a/fern/openapi.json b/fern/openapi.json index 9d8de461..74c73bb3 100644 --- a/fern/openapi.json +++ b/fern/openapi.json @@ -4961,6 +4961,87 @@ "title": "Agent Id" } }, + { + "name": "before", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "File ID cursor for pagination. Returns files that come before this file ID in the specified sort order", + "title": "Before" + }, + "description": "File ID cursor for pagination. Returns files that come before this file ID in the specified sort order" + }, + { + "name": "after", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "File ID cursor for pagination. Returns files that come after this file ID in the specified sort order", + "title": "After" + }, + "description": "File ID cursor for pagination. Returns files that come after this file ID in the specified sort order" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Maximum number of files to return", + "default": 100, + "title": "Limit" + }, + "description": "Maximum number of files to return" + }, + { + "name": "order", + "in": "query", + "required": false, + "schema": { + "enum": ["asc", "desc"], + "type": "string", + "description": "Sort order for files by creation time. 'asc' for oldest first, 'desc' for newest first", + "default": "desc", + "title": "Order" + }, + "description": "Sort order for files by creation time. 'asc' for oldest first, 'desc' for newest first" + }, + { + "name": "order_by", + "in": "query", + "required": false, + "schema": { + "const": "created_at", + "type": "string", + "description": "Field to sort by", + "default": "created_at", + "title": "Order By" + }, + "description": "Field to sort by" + }, { "name": "cursor", "in": "query", @@ -4974,24 +5055,12 @@ "type": "null" } ], - "description": "Pagination cursor from previous response", + "description": "Pagination cursor from previous response (deprecated, use before/after)", + "deprecated": true, "title": "Cursor" }, - "description": "Pagination cursor from previous response" - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 100, - "minimum": 1, - "description": "Number of items to return (1-100)", - "default": 20, - "title": "Limit" - }, - "description": "Number of items to return (1-100)" + "description": "Pagination cursor from previous response (deprecated, use before/after)", + "deprecated": true }, { "name": "is_open", diff --git a/letta/server/rest_api/routers/v1/agents.py b/letta/server/rest_api/routers/v1/agents.py index d8691119..cc7e96a4 100644 --- a/letta/server/rest_api/routers/v1/agents.py +++ b/letta/server/rest_api/routers/v1/agents.py @@ -794,8 +794,20 @@ async def list_agent_folders( @router.get("/{agent_id}/files", response_model=PaginatedAgentFiles, operation_id="list_agent_files") async def list_agent_files( agent_id: str, - cursor: Optional[str] = Query(None, description="Pagination cursor from previous response"), - limit: int = Query(20, ge=1, le=100, description="Number of items to return (1-100)"), + before: Optional[str] = Query( + None, description="File ID cursor for pagination. Returns files that come before this file ID in the specified sort order" + ), + after: Optional[str] = Query( + None, description="File ID cursor for pagination. Returns files that come after this file ID in the specified sort order" + ), + limit: Optional[int] = Query(100, description="Maximum number of files to return"), + order: Literal["asc", "desc"] = Query( + "desc", description="Sort order for files by creation time. 'asc' for oldest first, 'desc' for newest first" + ), + order_by: Literal["created_at"] = Query("created_at", description="Field to sort by"), + cursor: Optional[str] = Query( + None, description="Pagination cursor from previous response (deprecated, use before/after)", deprecated=True + ), is_open: Optional[bool] = Query(None, description="Filter by open status (true for open files, false for closed files)"), server: "SyncServer" = Depends(get_letta_server), headers: HeaderParams = Depends(get_headers), @@ -805,9 +817,18 @@ async def list_agent_files( """ actor = await server.user_manager.get_actor_or_default_async(actor_id=headers.actor_id) + effective_limit = limit or 20 + # get paginated file-agent relationships for this agent file_agents, next_cursor, has_more = await server.file_agent_manager.list_files_for_agent_paginated( - agent_id=agent_id, actor=actor, cursor=cursor, limit=limit, is_open=is_open + agent_id=agent_id, + actor=actor, + cursor=cursor, # keep for backwards compatibility + limit=effective_limit, + is_open=is_open, + before=before, + after=after, + ascending=(order == "asc"), ) # enrich with file and source metadata diff --git a/letta/services/files_agents_manager.py b/letta/services/files_agents_manager.py index 6b56c279..f993f89b 100644 --- a/letta/services/files_agents_manager.py +++ b/letta/services/files_agents_manager.py @@ -300,6 +300,9 @@ class FileAgentManager: cursor: Optional[str] = None, limit: int = 20, is_open: Optional[bool] = None, + before: Optional[str] = None, + after: Optional[str] = None, + ascending: bool = False, ) -> tuple[List[PydanticFileAgent], Optional[str], bool]: """ Return paginated file associations for an agent. @@ -307,9 +310,12 @@ class FileAgentManager: Args: agent_id: The agent ID to get files for actor: User performing the action - cursor: Pagination cursor (file-agent ID to start after) + cursor: Pagination cursor (file-agent ID to start after) - deprecated, use before/after limit: Maximum number of results to return is_open: Optional filter for open/closed status (None = all, True = open only, False = closed only) + before: File-agent ID cursor for pagination. Returns files that come before this ID in the specified sort order + after: File-agent ID cursor for pagination. Returns files that come after this ID in the specified sort order + ascending: Sort order (True = ascending by created_at/id, False = descending) Returns: Tuple of (file_agents, next_cursor, has_more) @@ -325,14 +331,27 @@ class FileAgentManager: if is_open is not None: conditions.append(FileAgentModel.is_open == is_open) - # apply cursor if provided (get records after this ID) - if cursor: + # handle pagination cursors (support both old and new style) + if before: + conditions.append(FileAgentModel.id < before) + elif after: + conditions.append(FileAgentModel.id > after) + elif cursor: + # fallback to old cursor behavior for backwards compatibility conditions.append(FileAgentModel.id > cursor) query = select(FileAgentModel).where(and_(*conditions)) - # order by ID for stable pagination - query = query.order_by(FileAgentModel.id) + # apply sorting based on pagination method + if before or after: + # For new cursor-based pagination, use created_at + id ordering + if ascending: + query = query.order_by(FileAgentModel.created_at.asc(), FileAgentModel.id.asc()) + else: + query = query.order_by(FileAgentModel.created_at.desc(), FileAgentModel.id.desc()) + else: + # For old cursor compatibility, maintain original behavior (ascending by ID) + query = query.order_by(FileAgentModel.id) # fetch limit + 1 to check if there are more results query = query.limit(limit + 1)