From 4b17cc996363beb15abc636f84cf2149a0de0ce4 Mon Sep 17 00:00:00 2001 From: Swifty <craigswift13@gmail.com> Date: Fri, 10 Jan 2025 12:57:35 +0100 Subject: [PATCH] feat(backend): Add Support for Managing Agent Presets with Pagination and Soft Delete (#9211) #### Summary - **New Models**: Added `LibraryAgentPreset`, `LibraryAgentPresetResponse`, `Pagination`, and `CreateLibraryAgentPresetRequest`. - **Database Changes**: - Added `isDeleted` column in `AgentPreset` for soft delete. - CRUD operations for `AgentPreset`: - `get_presets` with pagination. - `get_preset` by ID. - `create_or_update_preset` for upsert. - `delete_preset` to soft delete. - **API Routes**: - `GET /presets`: Fetch paginated presets. - `GET /presets/{preset_id}`: Fetch a single preset. - `POST /presets`: Create a preset. - `PUT /presets/{preset_id}`: Update a preset. - `DELETE /presets/{preset_id}`: Soft delete a preset. - **Tests**: - Coverage for models, CRUD operations, and pagination. - **Migration**: - Added `isDeleted` field to support soft delete. #### Review Notes - Validate migration scripts and test coverage. - Ensure API aligns with project standards. --------- Co-authored-by: Reinier van der Leer <pwuts@agpt.co> --- .../backend/backend/data/execution.py | 2 + .../backend/backend/executor/manager.py | 2 + .../backend/backend/server/model.py | 15 + .../backend/backend/server/rest_api.py | 54 +++ .../backend/backend/server/routers/v1.py | 70 ++-- .../backend/backend/server/v2/library/db.py | 318 ++++++++++++++---- .../backend/server/v2/library/db_test.py | 67 ++-- .../backend/server/v2/library/model.py | 101 +++++- .../backend/server/v2/library/model_test.py | 165 ++++++++- .../backend/server/v2/library/routes.py | 123 ------- .../server/v2/library/routes/__init__.py | 9 + .../server/v2/library/routes/agents.py | 148 ++++++++ .../server/v2/library/routes/presets.py | 156 +++++++++ .../backend/server/v2/library/routes_test.py | 30 +- .../migration.sql | 2 + .../migration.sql | 46 +++ .../migration.sql | 2 + autogpt_platform/backend/schema.prisma | 12 +- .../backend/test/executor/test_manager.py | 190 ++++++++++- .../backend/test/test_data_creator.py | 4 +- 20 files changed, 1195 insertions(+), 321 deletions(-) delete mode 100644 autogpt_platform/backend/backend/server/v2/library/routes.py create mode 100644 autogpt_platform/backend/backend/server/v2/library/routes/__init__.py create mode 100644 autogpt_platform/backend/backend/server/v2/library/routes/agents.py create mode 100644 autogpt_platform/backend/backend/server/v2/library/routes/presets.py create mode 100644 autogpt_platform/backend/migrations/20250107095812_preset_soft_delete/migration.sql create mode 100644 autogpt_platform/backend/migrations/20250108084101_user_agent_to_library_agent/migration.sql create mode 100644 autogpt_platform/backend/migrations/20250108084305_use_graph_is_active_version/migration.sql diff --git a/autogpt_platform/backend/backend/data/execution.py b/autogpt_platform/backend/backend/data/execution.py index 230c8e497..8bce9611f 100644 --- a/autogpt_platform/backend/backend/data/execution.py +++ b/autogpt_platform/backend/backend/data/execution.py @@ -136,6 +136,7 @@ async def create_graph_execution( graph_version: int, nodes_input: list[tuple[str, BlockInput]], user_id: str, + preset_id: str | None = None, ) -> tuple[str, list[ExecutionResult]]: """ Create a new AgentGraphExecution record. @@ -163,6 +164,7 @@ async def create_graph_execution( ] }, "userId": user_id, + "agentPresetId": preset_id, }, include=GRAPH_EXECUTION_INCLUDE, ) diff --git a/autogpt_platform/backend/backend/executor/manager.py b/autogpt_platform/backend/backend/executor/manager.py index e58f80133..d253309fe 100644 --- a/autogpt_platform/backend/backend/executor/manager.py +++ b/autogpt_platform/backend/backend/executor/manager.py @@ -781,6 +781,7 @@ class ExecutionManager(AppService): data: BlockInput, user_id: str, graph_version: int, + preset_id: str | None = None, ) -> GraphExecutionEntry: graph: GraphModel | None = self.db_client.get_graph( graph_id=graph_id, user_id=user_id, version=graph_version @@ -829,6 +830,7 @@ class ExecutionManager(AppService): graph_version=graph.version, nodes_input=nodes_input, user_id=user_id, + preset_id=preset_id, ) starting_node_execs = [] diff --git a/autogpt_platform/backend/backend/server/model.py b/autogpt_platform/backend/backend/server/model.py index 7c554b445..9c1d357da 100644 --- a/autogpt_platform/backend/backend/server/model.py +++ b/autogpt_platform/backend/backend/server/model.py @@ -56,3 +56,18 @@ class SetGraphActiveVersion(pydantic.BaseModel): class UpdatePermissionsRequest(pydantic.BaseModel): permissions: List[APIKeyPermission] + + +class Pagination(pydantic.BaseModel): + total_items: int = pydantic.Field( + description="Total number of items.", examples=[42] + ) + total_pages: int = pydantic.Field( + description="Total number of pages.", examples=[2] + ) + current_page: int = pydantic.Field( + description="Current_page page number.", examples=[1] + ) + page_size: int = pydantic.Field( + description="Number of items per page.", examples=[25] + ) diff --git a/autogpt_platform/backend/backend/server/rest_api.py b/autogpt_platform/backend/backend/server/rest_api.py index 762cf48fe..f62bf578d 100644 --- a/autogpt_platform/backend/backend/server/rest_api.py +++ b/autogpt_platform/backend/backend/server/rest_api.py @@ -17,6 +17,7 @@ import backend.data.db import backend.data.graph import backend.data.user import backend.server.routers.v1 +import backend.server.v2.library.model import backend.server.v2.library.routes import backend.server.v2.store.model import backend.server.v2.store.routes @@ -166,6 +167,59 @@ class AgentServer(backend.util.service.AppProcess): async def test_delete_graph(graph_id: str, user_id: str): return await backend.server.routers.v1.delete_graph(graph_id, user_id) + @staticmethod + async def test_get_presets(user_id: str, page: int = 1, page_size: int = 10): + return await backend.server.v2.library.routes.presets.get_presets( + user_id=user_id, page=page, page_size=page_size + ) + + @staticmethod + async def test_get_preset(preset_id: str, user_id: str): + return await backend.server.v2.library.routes.presets.get_preset( + preset_id=preset_id, user_id=user_id + ) + + @staticmethod + async def test_create_preset( + preset: backend.server.v2.library.model.CreateLibraryAgentPresetRequest, + user_id: str, + ): + return await backend.server.v2.library.routes.presets.create_preset( + preset=preset, user_id=user_id + ) + + @staticmethod + async def test_update_preset( + preset_id: str, + preset: backend.server.v2.library.model.CreateLibraryAgentPresetRequest, + user_id: str, + ): + return await backend.server.v2.library.routes.presets.update_preset( + preset_id=preset_id, preset=preset, user_id=user_id + ) + + @staticmethod + async def test_delete_preset(preset_id: str, user_id: str): + return await backend.server.v2.library.routes.presets.delete_preset( + preset_id=preset_id, user_id=user_id + ) + + @staticmethod + async def test_execute_preset( + graph_id: str, + graph_version: int, + preset_id: str, + node_input: dict[typing.Any, typing.Any], + user_id: str, + ): + return await backend.server.v2.library.routes.presets.execute_preset( + graph_id=graph_id, + graph_version=graph_version, + preset_id=preset_id, + node_input=node_input, + user_id=user_id, + ) + @staticmethod async def test_create_store_listing( request: backend.server.v2.store.model.StoreSubmissionRequest, user_id: str diff --git a/autogpt_platform/backend/backend/server/routers/v1.py b/autogpt_platform/backend/backend/server/routers/v1.py index 7bfd41b50..36a45fae7 100644 --- a/autogpt_platform/backend/backend/server/routers/v1.py +++ b/autogpt_platform/backend/backend/server/routers/v1.py @@ -13,6 +13,7 @@ from typing_extensions import Optional, TypedDict import backend.data.block import backend.server.integrations.router import backend.server.routers.analytics +import backend.server.v2.library.db from backend.data import execution as execution_db from backend.data import graph as graph_db from backend.data.api_key import ( @@ -180,11 +181,6 @@ async def get_graph( tags=["graphs"], dependencies=[Depends(auth_middleware)], ) -@v1_router.get( - path="/templates/{graph_id}/versions", - tags=["templates", "graphs"], - dependencies=[Depends(auth_middleware)], -) async def get_graph_all_versions( graph_id: str, user_id: Annotated[str, Depends(get_user_id)] ) -> Sequence[graph_db.GraphModel]: @@ -223,6 +219,13 @@ async def do_create_graph( 400, detail=f"Template #{create_graph.template_id} not found" ) graph.version = 1 + + # Create a library agent for the new graph + await backend.server.v2.library.db.create_library_agent( + graph.id, + graph.version, + user_id, + ) else: raise HTTPException( status_code=400, detail="Either graph or template_id must be provided." @@ -257,11 +260,6 @@ async def delete_graph( @v1_router.put( path="/graphs/{graph_id}", tags=["graphs"], dependencies=[Depends(auth_middleware)] ) -@v1_router.put( - path="/templates/{graph_id}", - tags=["templates", "graphs"], - dependencies=[Depends(auth_middleware)], -) async def update_graph( graph_id: str, graph: graph_db.Graph, @@ -294,6 +292,11 @@ async def update_graph( if new_graph_version.is_active: + # Keep the library agent up to date with the new active version + await backend.server.v2.library.db.update_agent_version_in_library( + user_id, graph.id, graph.version + ) + def get_credentials(credentials_id: str) -> "Credentials | None": return integration_creds_manager.get(user_id, credentials_id) @@ -349,6 +352,12 @@ async def set_graph_active_version( version=new_active_version, user_id=user_id, ) + + # Keep the library agent up to date with the new active version + await backend.server.v2.library.db.update_agent_version_in_library( + user_id, new_active_graph.id, new_active_graph.version + ) + if current_active_graph and current_active_graph.version != new_active_version: # Handle deactivation of the previously active version await on_graph_deactivate( @@ -425,47 +434,6 @@ async def get_graph_run_node_execution_results( return await execution_db.get_execution_results(graph_exec_id) -######################################################## -##################### Templates ######################## -######################################################## - - -@v1_router.get( - path="/templates", - tags=["graphs", "templates"], - dependencies=[Depends(auth_middleware)], -) -async def get_templates( - user_id: Annotated[str, Depends(get_user_id)] -) -> Sequence[graph_db.GraphModel]: - return await graph_db.get_graphs(filter_by="template", user_id=user_id) - - -@v1_router.get( - path="/templates/{graph_id}", - tags=["templates", "graphs"], - dependencies=[Depends(auth_middleware)], -) -async def get_template( - graph_id: str, version: int | None = None -) -> graph_db.GraphModel: - graph = await graph_db.get_graph(graph_id, version) - if not graph: - raise HTTPException(status_code=404, detail=f"Template #{graph_id} not found.") - return graph - - -@v1_router.post( - path="/templates", - tags=["templates", "graphs"], - dependencies=[Depends(auth_middleware)], -) -async def create_new_template( - create_graph: CreateGraph, user_id: Annotated[str, Depends(get_user_id)] -) -> graph_db.GraphModel: - return await do_create_graph(create_graph, user_id=user_id) - - ######################################################## ##################### Schedules ######################## ######################################################## diff --git a/autogpt_platform/backend/backend/server/v2/library/db.py b/autogpt_platform/backend/backend/server/v2/library/db.py index 8d142ef40..97313ccdb 100644 --- a/autogpt_platform/backend/backend/server/v2/library/db.py +++ b/autogpt_platform/backend/backend/server/v2/library/db.py @@ -1,5 +1,5 @@ +import json import logging -from typing import List import prisma.errors import prisma.models @@ -7,6 +7,7 @@ import prisma.types import backend.data.graph import backend.data.includes +import backend.server.model import backend.server.v2.library.model import backend.server.v2.store.exceptions @@ -14,90 +15,152 @@ logger = logging.getLogger(__name__) async def get_library_agents( - user_id: str, -) -> List[backend.server.v2.library.model.LibraryAgent]: - """ - Returns all agents (AgentGraph) that belong to the user and all agents in their library (UserAgent table) - """ - logger.debug(f"Getting library agents for user {user_id}") + user_id: str, search_query: str | None = None +) -> list[backend.server.v2.library.model.LibraryAgent]: + logger.debug( + f"Fetching library agents for user_id={user_id} search_query={search_query}" + ) - try: - # Get agents created by user with nodes and links - user_created = await prisma.models.AgentGraph.prisma().find_many( - where=prisma.types.AgentGraphWhereInput(userId=user_id, isActive=True), - include=backend.data.includes.AGENT_GRAPH_INCLUDE, + if search_query and len(search_query.strip()) > 100: + logger.warning(f"Search query too long: {search_query}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Search query is too long." ) - # Get agents in user's library with nodes and links - library_agents = await prisma.models.UserAgent.prisma().find_many( - where=prisma.types.UserAgentWhereInput( - userId=user_id, isDeleted=False, isArchived=False - ), - include={ + where_clause = prisma.types.LibraryAgentWhereInput( + userId=user_id, + isDeleted=False, + isArchived=False, + ) + + if search_query: + where_clause["OR"] = [ + { "Agent": { - "include": { - "AgentNodes": { - "include": { - "Input": True, - "Output": True, - "Webhook": True, - "AgentBlock": True, - } - } + "is": {"name": {"contains": search_query, "mode": "insensitive"}} + } + }, + { + "Agent": { + "is": { + "description": {"contains": search_query, "mode": "insensitive"} } } }, + ] + + try: + library_agents = await prisma.models.LibraryAgent.prisma().find_many( + where=where_clause, + include={ + "Agent": { + "include": { + "AgentNodes": {"include": {"Input": True, "Output": True}} + } + } + }, + order=[{"updatedAt": "desc"}], + ) + logger.debug(f"Retrieved {len(library_agents)} agents for user_id={user_id}.") + return [ + backend.server.v2.library.model.LibraryAgent.from_db(agent) + for agent in library_agents + ] + except prisma.errors.PrismaError as e: + logger.error(f"Database error fetching library agents: {e}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Unable to fetch library agents." ) - # Convert to Graph models first - graphs = [] - # Add user created agents - for agent in user_created: - try: - graphs.append(backend.data.graph.GraphModel.from_db(agent)) - except Exception as e: - logger.error(f"Error processing user created agent {agent.id}: {e}") - continue +async def create_library_agent( + agent_id: str, agent_version: int, user_id: str +) -> prisma.models.LibraryAgent: + """ + Adds an agent to the user's library (LibraryAgent table) + """ - # Add library agents - for agent in library_agents: - if agent.Agent: - try: - graphs.append(backend.data.graph.GraphModel.from_db(agent.Agent)) - except Exception as e: - logger.error(f"Error processing library agent {agent.agentId}: {e}") - continue + try: - # Convert Graph models to LibraryAgent models - result = [] - for graph in graphs: - result.append( - backend.server.v2.library.model.LibraryAgent( - id=graph.id, - version=graph.version, - is_active=graph.is_active, - name=graph.name, - description=graph.description, - isCreatedByUser=any(a.id == graph.id for a in user_created), - input_schema=graph.input_schema, - output_schema=graph.output_schema, - ) + library_agent = await prisma.models.LibraryAgent.prisma().create( + data=prisma.types.LibraryAgentCreateInput( + userId=user_id, + agentId=agent_id, + agentVersion=agent_version, + isCreatedByUser=False, + useGraphIsActiveVersion=True, ) - - logger.debug(f"Found {len(result)} library agents") - return result - + ) + return library_agent except prisma.errors.PrismaError as e: - logger.error(f"Database error getting library agents: {str(e)}") + logger.error(f"Database error creating agent to library: {str(e)}") raise backend.server.v2.store.exceptions.DatabaseError( - "Failed to fetch library agents" + "Failed to create agent to library" ) from e -async def add_agent_to_library(store_listing_version_id: str, user_id: str) -> None: +async def update_agent_version_in_library( + user_id: str, agent_id: str, agent_version: int +) -> None: """ - Finds the agent from the store listing version and adds it to the user's library (UserAgent table) + Updates the agent version in the library + """ + try: + await prisma.models.LibraryAgent.prisma().update( + where={ + "userId": user_id, + "agentId": agent_id, + "useGraphIsActiveVersion": True, + }, + data=prisma.types.LibraryAgentUpdateInput( + Agent=prisma.types.AgentGraphUpdateOneWithoutRelationsInput( + connect=prisma.types.AgentGraphWhereUniqueInput( + id=agent_id, + version=agent_version, + ), + ), + ), + ) + except prisma.errors.PrismaError as e: + logger.error(f"Database error updating agent version in library: {str(e)}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to update agent version in library" + ) from e + + +async def update_library_agent( + library_agent_id: str, + user_id: str, + auto_update_version: bool = False, + is_favorite: bool = False, + is_archived: bool = False, + is_deleted: bool = False, +) -> None: + """ + Updates the library agent with the given fields + """ + try: + await prisma.models.LibraryAgent.prisma().update( + where={"id": library_agent_id, "userId": user_id}, + data=prisma.types.LibraryAgentUpdateInput( + useGraphIsActiveVersion=auto_update_version, + isFavorite=is_favorite, + isArchived=is_archived, + isDeleted=is_deleted, + ), + ) + except prisma.errors.PrismaError as e: + logger.error(f"Database error updating library agent: {str(e)}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to update library agent" + ) from e + + +async def add_store_agent_to_library( + store_listing_version_id: str, user_id: str +) -> None: + """ + Finds the agent from the store listing version and adds it to the user's library (LibraryAgent table) if they don't already have it """ logger.debug( @@ -131,7 +194,7 @@ async def add_agent_to_library(store_listing_version_id: str, user_id: str) -> N ) # Check if user already has this agent - existing_user_agent = await prisma.models.UserAgent.prisma().find_first( + existing_user_agent = await prisma.models.LibraryAgent.prisma().find_first( where={ "userId": user_id, "agentId": agent.id, @@ -145,9 +208,9 @@ async def add_agent_to_library(store_listing_version_id: str, user_id: str) -> N ) return - # Create UserAgent entry - await prisma.models.UserAgent.prisma().create( - data=prisma.types.UserAgentCreateInput( + # Create LibraryAgent entry + await prisma.models.LibraryAgent.prisma().create( + data=prisma.types.LibraryAgentCreateInput( userId=user_id, agentId=agent.id, agentVersion=agent.version, @@ -163,3 +226,118 @@ async def add_agent_to_library(store_listing_version_id: str, user_id: str) -> N raise backend.server.v2.store.exceptions.DatabaseError( "Failed to add agent to library" ) from e + + +############################################## +########### Presets DB Functions ############# +############################################## + + +async def get_presets( + user_id: str, page: int, page_size: int +) -> backend.server.v2.library.model.LibraryAgentPresetResponse: + + try: + presets = await prisma.models.AgentPreset.prisma().find_many( + where={"userId": user_id}, + skip=page * page_size, + take=page_size, + ) + + total_items = await prisma.models.AgentPreset.prisma().count( + where={"userId": user_id}, + ) + total_pages = (total_items + page_size - 1) // page_size + + presets = [ + backend.server.v2.library.model.LibraryAgentPreset.from_db(preset) + for preset in presets + ] + + return backend.server.v2.library.model.LibraryAgentPresetResponse( + presets=presets, + pagination=backend.server.model.Pagination( + total_items=total_items, + total_pages=total_pages, + current_page=page, + page_size=page_size, + ), + ) + + except prisma.errors.PrismaError as e: + logger.error(f"Database error getting presets: {str(e)}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to fetch presets" + ) from e + + +async def get_preset( + user_id: str, preset_id: str +) -> backend.server.v2.library.model.LibraryAgentPreset | None: + try: + preset = await prisma.models.AgentPreset.prisma().find_unique( + where={"id": preset_id, "userId": user_id}, include={"InputPresets": True} + ) + if not preset: + return None + return backend.server.v2.library.model.LibraryAgentPreset.from_db(preset) + except prisma.errors.PrismaError as e: + logger.error(f"Database error getting preset: {str(e)}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to fetch preset" + ) from e + + +async def create_or_update_preset( + user_id: str, + preset: backend.server.v2.library.model.CreateLibraryAgentPresetRequest, + preset_id: str | None = None, +) -> backend.server.v2.library.model.LibraryAgentPreset: + try: + + logger.info(f"DB Creating Preset with inputs: {preset.inputs}") + new_preset = await prisma.models.AgentPreset.prisma().upsert( + where={ + "id": preset_id if preset_id else "", + }, + data={ + "create": { + "userId": user_id, + "name": preset.name, + "description": preset.description, + "agentId": preset.agent_id, + "agentVersion": preset.agent_version, + "isActive": preset.is_active, + "InputPresets": { + "create": [ + {"name": name, "data": json.dumps(data)} + for name, data in preset.inputs.items() + ] + }, + }, + "update": { + "name": preset.name, + "description": preset.description, + "isActive": preset.is_active, + }, + }, + ) + return backend.server.v2.library.model.LibraryAgentPreset.from_db(new_preset) + except prisma.errors.PrismaError as e: + logger.error(f"Database error creating preset: {str(e)}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to create preset" + ) from e + + +async def delete_preset(user_id: str, preset_id: str) -> None: + try: + await prisma.models.AgentPreset.prisma().update( + where={"id": preset_id, "userId": user_id}, + data={"isDeleted": True}, + ) + except prisma.errors.PrismaError as e: + logger.error(f"Database error deleting preset: {str(e)}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to delete preset" + ) from e diff --git a/autogpt_platform/backend/backend/server/v2/library/db_test.py b/autogpt_platform/backend/backend/server/v2/library/db_test.py index e06d4bfa9..17e3bdb4e 100644 --- a/autogpt_platform/backend/backend/server/v2/library/db_test.py +++ b/autogpt_platform/backend/backend/server/v2/library/db_test.py @@ -37,7 +37,7 @@ async def test_get_library_agents(mocker): ] mock_library_agents = [ - prisma.models.UserAgent( + prisma.models.LibraryAgent( id="ua1", userId="test-user", agentId="agent2", @@ -48,6 +48,7 @@ async def test_get_library_agents(mocker): createdAt=datetime.now(), updatedAt=datetime.now(), isFavorite=False, + useGraphIsActiveVersion=True, Agent=prisma.models.AgentGraph( id="agent2", version=1, @@ -67,8 +68,8 @@ async def test_get_library_agents(mocker): return_value=mock_user_created ) - mock_user_agent = mocker.patch("prisma.models.UserAgent.prisma") - mock_user_agent.return_value.find_many = mocker.AsyncMock( + mock_library_agent = mocker.patch("prisma.models.LibraryAgent.prisma") + mock_library_agent.return_value.find_many = mocker.AsyncMock( return_value=mock_library_agents ) @@ -76,40 +77,16 @@ async def test_get_library_agents(mocker): result = await db.get_library_agents("test-user") # Verify results - assert len(result) == 2 - assert result[0].id == "agent1" - assert result[0].name == "Test Agent 1" - assert result[0].description == "Test Description 1" - assert result[0].isCreatedByUser is True - assert result[1].id == "agent2" - assert result[1].name == "Test Agent 2" - assert result[1].description == "Test Description 2" - assert result[1].isCreatedByUser is False - - # Verify mocks called correctly - mock_agent_graph.return_value.find_many.assert_called_once_with( - where=prisma.types.AgentGraphWhereInput(userId="test-user", isActive=True), - include=backend.data.includes.AGENT_GRAPH_INCLUDE, - ) - mock_user_agent.return_value.find_many.assert_called_once_with( - where=prisma.types.UserAgentWhereInput( - userId="test-user", isDeleted=False, isArchived=False - ), - include={ - "Agent": { - "include": { - "AgentNodes": { - "include": { - "Input": True, - "Output": True, - "Webhook": True, - "AgentBlock": True, - } - } - } - } - }, - ) + assert len(result) == 1 + assert result[0].id == "ua1" + assert result[0].name == "Test Agent 2" + assert result[0].description == "Test Description 2" + assert result[0].is_created_by_user is False + assert result[0].is_latest_version is True + assert result[0].is_favorite is False + assert result[0].agent_id == "agent2" + assert result[0].agent_version == 1 + assert result[0].preset_id is None @pytest.mark.asyncio @@ -152,26 +129,26 @@ async def test_add_agent_to_library(mocker): return_value=mock_store_listing ) - mock_user_agent = mocker.patch("prisma.models.UserAgent.prisma") - mock_user_agent.return_value.find_first = mocker.AsyncMock(return_value=None) - mock_user_agent.return_value.create = mocker.AsyncMock() + mock_library_agent = mocker.patch("prisma.models.LibraryAgent.prisma") + mock_library_agent.return_value.find_first = mocker.AsyncMock(return_value=None) + mock_library_agent.return_value.create = mocker.AsyncMock() # Call function - await db.add_agent_to_library("version123", "test-user") + await db.add_store_agent_to_library("version123", "test-user") # Verify mocks called correctly mock_store_listing_version.return_value.find_unique.assert_called_once_with( where={"id": "version123"}, include={"Agent": True} ) - mock_user_agent.return_value.find_first.assert_called_once_with( + mock_library_agent.return_value.find_first.assert_called_once_with( where={ "userId": "test-user", "agentId": "agent1", "agentVersion": 1, } ) - mock_user_agent.return_value.create.assert_called_once_with( - data=prisma.types.UserAgentCreateInput( + mock_library_agent.return_value.create.assert_called_once_with( + data=prisma.types.LibraryAgentCreateInput( userId="test-user", agentId="agent1", agentVersion=1, isCreatedByUser=False ) ) @@ -189,7 +166,7 @@ async def test_add_agent_to_library_not_found(mocker): # Call function and verify exception with pytest.raises(backend.server.v2.store.exceptions.AgentNotFoundError): - await db.add_agent_to_library("version123", "test-user") + await db.add_store_agent_to_library("version123", "test-user") # Verify mock called correctly mock_store_listing_version.return_value.find_unique.assert_called_once_with( diff --git a/autogpt_platform/backend/backend/server/v2/library/model.py b/autogpt_platform/backend/backend/server/v2/library/model.py index 88a81f6d7..86e42f8a0 100644 --- a/autogpt_platform/backend/backend/server/v2/library/model.py +++ b/autogpt_platform/backend/backend/server/v2/library/model.py @@ -1,16 +1,111 @@ +import datetime +import json import typing +import prisma.models import pydantic +import backend.data.block +import backend.data.graph +import backend.server.model + class LibraryAgent(pydantic.BaseModel): id: str # Changed from agent_id to match GraphMeta - version: int # Changed from agent_version to match GraphMeta - is_active: bool # Added to match GraphMeta + + agent_id: str + agent_version: int # Changed from agent_version to match GraphMeta + + preset_id: str | None + + updated_at: datetime.datetime + name: str description: str - isCreatedByUser: bool # Made input_schema and output_schema match GraphMeta's type input_schema: dict[str, typing.Any] # Should be BlockIOObjectSubSchema in frontend output_schema: dict[str, typing.Any] # Should be BlockIOObjectSubSchema in frontend + + is_favorite: bool + is_created_by_user: bool + + is_latest_version: bool + + @staticmethod + def from_db(agent: prisma.models.LibraryAgent): + if not agent.Agent: + raise ValueError("AgentGraph is required") + + graph = backend.data.graph.GraphModel.from_db(agent.Agent) + + agent_updated_at = agent.Agent.updatedAt + lib_agent_updated_at = agent.updatedAt + + # Take the latest updated_at timestamp either when the graph was updated or the library agent was updated + updated_at = ( + max(agent_updated_at, lib_agent_updated_at) + if agent_updated_at + else lib_agent_updated_at + ) + + return LibraryAgent( + id=agent.id, + agent_id=agent.agentId, + agent_version=agent.agentVersion, + updated_at=updated_at, + name=graph.name, + description=graph.description, + input_schema=graph.input_schema, + output_schema=graph.output_schema, + is_favorite=agent.isFavorite, + is_created_by_user=agent.isCreatedByUser, + is_latest_version=graph.is_active, + preset_id=agent.AgentPreset.id if agent.AgentPreset else None, + ) + + +class LibraryAgentPreset(pydantic.BaseModel): + id: str + updated_at: datetime.datetime + + agent_id: str + agent_version: int + + name: str + description: str + + is_active: bool + inputs: dict[str, typing.Union[backend.data.block.BlockInput, typing.Any]] + + @staticmethod + def from_db(preset: prisma.models.AgentPreset): + input_data = {} + + for data in preset.InputPresets or []: + input_data[data.name] = json.loads(data.data) + + return LibraryAgentPreset( + id=preset.id, + updated_at=preset.updatedAt, + agent_id=preset.agentId, + agent_version=preset.agentVersion, + name=preset.name, + description=preset.description, + is_active=preset.isActive, + inputs=input_data, + ) + + +class LibraryAgentPresetResponse(pydantic.BaseModel): + presets: list[LibraryAgentPreset] + pagination: backend.server.model.Pagination + + +class CreateLibraryAgentPresetRequest(pydantic.BaseModel): + name: str + description: str + inputs: dict[str, typing.Union[backend.data.block.BlockInput, typing.Any]] + agent_id: str + agent_version: int + is_active: bool diff --git a/autogpt_platform/backend/backend/server/v2/library/model_test.py b/autogpt_platform/backend/backend/server/v2/library/model_test.py index 81aa8fe07..a2e7aa8f8 100644 --- a/autogpt_platform/backend/backend/server/v2/library/model_test.py +++ b/autogpt_platform/backend/backend/server/v2/library/model_test.py @@ -1,23 +1,35 @@ +import datetime + +import prisma.models + +import backend.data.block +import backend.server.model import backend.server.v2.library.model def test_library_agent(): agent = backend.server.v2.library.model.LibraryAgent( id="test-agent-123", - version=1, - is_active=True, + agent_id="agent-123", + agent_version=1, + preset_id=None, + updated_at=datetime.datetime.now(), name="Test Agent", description="Test description", - isCreatedByUser=False, input_schema={"type": "object", "properties": {}}, output_schema={"type": "object", "properties": {}}, + is_favorite=False, + is_created_by_user=False, + is_latest_version=True, ) assert agent.id == "test-agent-123" - assert agent.version == 1 - assert agent.is_active is True + assert agent.agent_id == "agent-123" + assert agent.agent_version == 1 assert agent.name == "Test Agent" assert agent.description == "Test description" - assert agent.isCreatedByUser is False + assert agent.is_favorite is False + assert agent.is_created_by_user is False + assert agent.is_latest_version is True assert agent.input_schema == {"type": "object", "properties": {}} assert agent.output_schema == {"type": "object", "properties": {}} @@ -25,19 +37,148 @@ def test_library_agent(): def test_library_agent_with_user_created(): agent = backend.server.v2.library.model.LibraryAgent( id="user-agent-456", - version=2, - is_active=True, + agent_id="agent-456", + agent_version=2, + preset_id=None, + updated_at=datetime.datetime.now(), name="User Created Agent", description="An agent created by the user", - isCreatedByUser=True, input_schema={"type": "object", "properties": {}}, output_schema={"type": "object", "properties": {}}, + is_favorite=False, + is_created_by_user=True, + is_latest_version=True, ) assert agent.id == "user-agent-456" - assert agent.version == 2 - assert agent.is_active is True + assert agent.agent_id == "agent-456" + assert agent.agent_version == 2 assert agent.name == "User Created Agent" assert agent.description == "An agent created by the user" - assert agent.isCreatedByUser is True + assert agent.is_favorite is False + assert agent.is_created_by_user is True + assert agent.is_latest_version is True assert agent.input_schema == {"type": "object", "properties": {}} assert agent.output_schema == {"type": "object", "properties": {}} + + +def test_library_agent_preset(): + preset = backend.server.v2.library.model.LibraryAgentPreset( + id="preset-123", + name="Test Preset", + description="Test preset description", + agent_id="test-agent-123", + agent_version=1, + is_active=True, + inputs={ + "input1": backend.data.block.BlockInput( + name="input1", + data={"type": "string", "value": "test value"}, + ) + }, + updated_at=datetime.datetime.now(), + ) + assert preset.id == "preset-123" + assert preset.name == "Test Preset" + assert preset.description == "Test preset description" + assert preset.agent_id == "test-agent-123" + assert preset.agent_version == 1 + assert preset.is_active is True + assert preset.inputs == { + "input1": backend.data.block.BlockInput( + name="input1", data={"type": "string", "value": "test value"} + ) + } + + +def test_library_agent_preset_response(): + preset = backend.server.v2.library.model.LibraryAgentPreset( + id="preset-123", + name="Test Preset", + description="Test preset description", + agent_id="test-agent-123", + agent_version=1, + is_active=True, + inputs={ + "input1": backend.data.block.BlockInput( + name="input1", + data={"type": "string", "value": "test value"}, + ) + }, + updated_at=datetime.datetime.now(), + ) + + pagination = backend.server.model.Pagination( + total_items=1, total_pages=1, current_page=1, page_size=10 + ) + + response = backend.server.v2.library.model.LibraryAgentPresetResponse( + presets=[preset], pagination=pagination + ) + + assert len(response.presets) == 1 + assert response.presets[0].id == "preset-123" + assert response.pagination.total_items == 1 + assert response.pagination.total_pages == 1 + assert response.pagination.current_page == 1 + assert response.pagination.page_size == 10 + + +def test_create_library_agent_preset_request(): + request = backend.server.v2.library.model.CreateLibraryAgentPresetRequest( + name="New Preset", + description="New preset description", + agent_id="agent-123", + agent_version=1, + is_active=True, + inputs={ + "input1": backend.data.block.BlockInput( + name="input1", + data={"type": "string", "value": "test value"}, + ) + }, + ) + + assert request.name == "New Preset" + assert request.description == "New preset description" + assert request.agent_id == "agent-123" + assert request.agent_version == 1 + assert request.is_active is True + assert request.inputs == { + "input1": backend.data.block.BlockInput( + name="input1", data={"type": "string", "value": "test value"} + ) + } + + +def test_library_agent_from_db(): + # Create mock DB agent + db_agent = prisma.models.AgentPreset( + id="test-agent-123", + createdAt=datetime.datetime.now(), + updatedAt=datetime.datetime.now(), + agentId="agent-123", + agentVersion=1, + name="Test Agent", + description="Test agent description", + isActive=True, + userId="test-user-123", + isDeleted=False, + InputPresets=[ + prisma.models.AgentNodeExecutionInputOutput( + id="input-123", + time=datetime.datetime.now(), + name="input1", + data='{"type": "string", "value": "test value"}', + ) + ], + ) + + # Convert to LibraryAgentPreset + agent = backend.server.v2.library.model.LibraryAgentPreset.from_db(db_agent) + + assert agent.id == "test-agent-123" + assert agent.agent_version == 1 + assert agent.is_active is True + assert agent.name == "Test Agent" + assert agent.description == "Test agent description" + assert agent.inputs == {"input1": {"type": "string", "value": "test value"}} diff --git a/autogpt_platform/backend/backend/server/v2/library/routes.py b/autogpt_platform/backend/backend/server/v2/library/routes.py deleted file mode 100644 index 3ee868025..000000000 --- a/autogpt_platform/backend/backend/server/v2/library/routes.py +++ /dev/null @@ -1,123 +0,0 @@ -import logging -import typing - -import autogpt_libs.auth.depends -import autogpt_libs.auth.middleware -import fastapi -import prisma - -import backend.data.graph -import backend.integrations.creds_manager -import backend.integrations.webhooks.graph_lifecycle_hooks -import backend.server.v2.library.db -import backend.server.v2.library.model - -logger = logging.getLogger(__name__) - -router = fastapi.APIRouter() -integration_creds_manager = ( - backend.integrations.creds_manager.IntegrationCredentialsManager() -) - - -@router.get( - "/agents", - tags=["library", "private"], - dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], -) -async def get_library_agents( - user_id: typing.Annotated[ - str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) - ] -) -> typing.Sequence[backend.server.v2.library.model.LibraryAgent]: - """ - Get all agents in the user's library, including both created and saved agents. - """ - try: - agents = await backend.server.v2.library.db.get_library_agents(user_id) - return agents - except Exception: - logger.exception("Exception occurred whilst getting library agents") - raise fastapi.HTTPException( - status_code=500, detail="Failed to get library agents" - ) - - -@router.post( - "/agents/{store_listing_version_id}", - tags=["library", "private"], - dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], - status_code=201, -) -async def add_agent_to_library( - store_listing_version_id: str, - user_id: typing.Annotated[ - str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) - ], -) -> fastapi.Response: - """ - Add an agent from the store to the user's library. - - Args: - store_listing_version_id (str): ID of the store listing version to add - user_id (str): ID of the authenticated user - - Returns: - fastapi.Response: 201 status code on success - - Raises: - HTTPException: If there is an error adding the agent to the library - """ - try: - # Get the graph from the store listing - store_listing_version = ( - await prisma.models.StoreListingVersion.prisma().find_unique( - where={"id": store_listing_version_id}, include={"Agent": True} - ) - ) - - if not store_listing_version or not store_listing_version.Agent: - raise fastapi.HTTPException( - status_code=404, - detail=f"Store listing version {store_listing_version_id} not found", - ) - - agent = store_listing_version.Agent - - if agent.userId == user_id: - raise fastapi.HTTPException( - status_code=400, detail="Cannot add own agent to library" - ) - - # Create a new graph from the template - graph = await backend.data.graph.get_graph( - agent.id, agent.version, user_id=user_id - ) - - if not graph: - raise fastapi.HTTPException( - status_code=404, detail=f"Agent {agent.id} not found" - ) - - # Create a deep copy with new IDs - graph.version = 1 - graph.is_template = False - graph.is_active = True - graph.reassign_ids(user_id=user_id, reassign_graph_id=True) - - # Save the new graph - graph = await backend.data.graph.create_graph(graph, user_id=user_id) - graph = ( - await backend.integrations.webhooks.graph_lifecycle_hooks.on_graph_activate( - graph, - get_credentials=lambda id: integration_creds_manager.get(user_id, id), - ) - ) - - return fastapi.Response(status_code=201) - - except Exception: - logger.exception("Exception occurred whilst adding agent to library") - raise fastapi.HTTPException( - status_code=500, detail="Failed to add agent to library" - ) diff --git a/autogpt_platform/backend/backend/server/v2/library/routes/__init__.py b/autogpt_platform/backend/backend/server/v2/library/routes/__init__.py new file mode 100644 index 000000000..f62cbe7ff --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/library/routes/__init__.py @@ -0,0 +1,9 @@ +import fastapi + +from .agents import router as agents_router +from .presets import router as presets_router + +router = fastapi.APIRouter() + +router.include_router(presets_router) +router.include_router(agents_router) diff --git a/autogpt_platform/backend/backend/server/v2/library/routes/agents.py b/autogpt_platform/backend/backend/server/v2/library/routes/agents.py new file mode 100644 index 000000000..e446e08ea --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/library/routes/agents.py @@ -0,0 +1,148 @@ +import logging +import typing + +import autogpt_libs.auth.depends +import autogpt_libs.auth.middleware +import autogpt_libs.utils.cache +import fastapi + +import backend.server.v2.library.db +import backend.server.v2.library.model +import backend.server.v2.store.exceptions + +logger = logging.getLogger(__name__) + +router = fastapi.APIRouter() + + +@router.get( + "/agents", + tags=["library", "private"], + dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], +) +async def get_library_agents( + user_id: typing.Annotated[ + str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) + ] +) -> typing.Sequence[backend.server.v2.library.model.LibraryAgent]: + """ + Get all agents in the user's library, including both created and saved agents. + """ + try: + agents = await backend.server.v2.library.db.get_library_agents(user_id) + return agents + except Exception as e: + logger.exception(f"Exception occurred whilst getting library agents: {e}") + raise fastapi.HTTPException( + status_code=500, detail="Failed to get library agents" + ) + + +@router.post( + "/agents/{store_listing_version_id}", + tags=["library", "private"], + dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], + status_code=201, +) +async def add_agent_to_library( + store_listing_version_id: str, + user_id: typing.Annotated[ + str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) + ], +) -> fastapi.Response: + """ + Add an agent from the store to the user's library. + + Args: + store_listing_version_id (str): ID of the store listing version to add + user_id (str): ID of the authenticated user + + Returns: + fastapi.Response: 201 status code on success + + Raises: + HTTPException: If there is an error adding the agent to the library + """ + try: + # Use the database function to add the agent to the library + await backend.server.v2.library.db.add_store_agent_to_library( + store_listing_version_id, user_id + ) + return fastapi.Response(status_code=201) + + except backend.server.v2.store.exceptions.AgentNotFoundError: + raise fastapi.HTTPException( + status_code=404, + detail=f"Store listing version {store_listing_version_id} not found", + ) + except backend.server.v2.store.exceptions.DatabaseError as e: + logger.exception(f"Database error occurred whilst adding agent to library: {e}") + raise fastapi.HTTPException( + status_code=500, detail="Failed to add agent to library" + ) + except Exception as e: + logger.exception( + f"Unexpected exception occurred whilst adding agent to library: {e}" + ) + raise fastapi.HTTPException( + status_code=500, detail="Failed to add agent to library" + ) + + +@router.put( + "/agents/{library_agent_id}", + tags=["library", "private"], + dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], + status_code=204, +) +async def update_library_agent( + library_agent_id: str, + user_id: typing.Annotated[ + str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) + ], + auto_update_version: bool = False, + is_favorite: bool = False, + is_archived: bool = False, + is_deleted: bool = False, +) -> fastapi.Response: + """ + Update the library agent with the given fields. + + Args: + library_agent_id (str): ID of the library agent to update + user_id (str): ID of the authenticated user + auto_update_version (bool): Whether to auto-update the agent version + is_favorite (bool): Whether the agent is marked as favorite + is_archived (bool): Whether the agent is archived + is_deleted (bool): Whether the agent is deleted + + Returns: + fastapi.Response: 204 status code on success + + Raises: + HTTPException: If there is an error updating the library agent + """ + try: + # Use the database function to update the library agent + await backend.server.v2.library.db.update_library_agent( + library_agent_id, + user_id, + auto_update_version, + is_favorite, + is_archived, + is_deleted, + ) + return fastapi.Response(status_code=204) + + except backend.server.v2.store.exceptions.DatabaseError as e: + logger.exception(f"Database error occurred whilst updating library agent: {e}") + raise fastapi.HTTPException( + status_code=500, detail="Failed to update library agent" + ) + except Exception as e: + logger.exception( + f"Unexpected exception occurred whilst updating library agent: {e}" + ) + raise fastapi.HTTPException( + status_code=500, detail="Failed to update library agent" + ) diff --git a/autogpt_platform/backend/backend/server/v2/library/routes/presets.py b/autogpt_platform/backend/backend/server/v2/library/routes/presets.py new file mode 100644 index 000000000..090a23231 --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/library/routes/presets.py @@ -0,0 +1,156 @@ +import logging +import typing + +import autogpt_libs.auth.depends +import autogpt_libs.auth.middleware +import autogpt_libs.utils.cache +import fastapi + +import backend.data.graph +import backend.executor +import backend.integrations.creds_manager +import backend.integrations.webhooks.graph_lifecycle_hooks +import backend.server.v2.library.db +import backend.server.v2.library.model +import backend.util.service + +logger = logging.getLogger(__name__) + +router = fastapi.APIRouter() +integration_creds_manager = ( + backend.integrations.creds_manager.IntegrationCredentialsManager() +) + + +@autogpt_libs.utils.cache.thread_cached +def execution_manager_client() -> backend.executor.ExecutionManager: + return backend.util.service.get_service_client(backend.executor.ExecutionManager) + + +@router.get("/presets") +async def get_presets( + user_id: typing.Annotated[ + str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) + ], + page: int = 1, + page_size: int = 10, +) -> backend.server.v2.library.model.LibraryAgentPresetResponse: + try: + presets = await backend.server.v2.library.db.get_presets( + user_id, page, page_size + ) + return presets + except Exception as e: + logger.exception(f"Exception occurred whilst getting presets: {e}") + raise fastapi.HTTPException(status_code=500, detail="Failed to get presets") + + +@router.get("/presets/{preset_id}") +async def get_preset( + preset_id: str, + user_id: typing.Annotated[ + str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) + ], +) -> backend.server.v2.library.model.LibraryAgentPreset: + try: + preset = await backend.server.v2.library.db.get_preset(user_id, preset_id) + if not preset: + raise fastapi.HTTPException( + status_code=404, + detail=f"Preset {preset_id} not found", + ) + return preset + except Exception as e: + logger.exception(f"Exception occurred whilst getting preset: {e}") + raise fastapi.HTTPException(status_code=500, detail="Failed to get preset") + + +@router.post("/presets") +async def create_preset( + preset: backend.server.v2.library.model.CreateLibraryAgentPresetRequest, + user_id: typing.Annotated[ + str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) + ], +) -> backend.server.v2.library.model.LibraryAgentPreset: + try: + return await backend.server.v2.library.db.create_or_update_preset( + user_id, preset + ) + except Exception as e: + logger.exception(f"Exception occurred whilst creating preset: {e}") + raise fastapi.HTTPException(status_code=500, detail="Failed to create preset") + + +@router.put("/presets/{preset_id}") +async def update_preset( + preset_id: str, + preset: backend.server.v2.library.model.CreateLibraryAgentPresetRequest, + user_id: typing.Annotated[ + str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) + ], +) -> backend.server.v2.library.model.LibraryAgentPreset: + try: + return await backend.server.v2.library.db.create_or_update_preset( + user_id, preset, preset_id + ) + except Exception as e: + logger.exception(f"Exception occurred whilst updating preset: {e}") + raise fastapi.HTTPException(status_code=500, detail="Failed to update preset") + + +@router.delete("/presets/{preset_id}") +async def delete_preset( + preset_id: str, + user_id: typing.Annotated[ + str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) + ], +): + try: + await backend.server.v2.library.db.delete_preset(user_id, preset_id) + return fastapi.Response(status_code=204) + except Exception as e: + logger.exception(f"Exception occurred whilst deleting preset: {e}") + raise fastapi.HTTPException(status_code=500, detail="Failed to delete preset") + + +@router.post( + path="/presets/{preset_id}/execute", + tags=["presets"], + dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], +) +async def execute_preset( + graph_id: str, + graph_version: int, + preset_id: str, + node_input: dict[typing.Any, typing.Any], + user_id: typing.Annotated[ + str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) + ], +) -> dict[str, typing.Any]: # FIXME: add proper return type + try: + preset = await backend.server.v2.library.db.get_preset(user_id, preset_id) + if not preset: + raise fastapi.HTTPException(status_code=404, detail="Preset not found") + + logger.info(f"Preset inputs: {preset.inputs}") + + updated_node_input = node_input.copy() + # Merge in preset input values + for key, value in preset.inputs.items(): + if key not in updated_node_input: + updated_node_input[key] = value + + execution = execution_manager_client().add_execution( + graph_id=graph_id, + graph_version=graph_version, + data=updated_node_input, + user_id=user_id, + preset_id=preset_id, + ) + + logger.info(f"Execution added: {execution} with input: {updated_node_input}") + + return {"id": execution.graph_exec_id} + except Exception as e: + msg = e.__str__().encode().decode("unicode_escape") + raise fastapi.HTTPException(status_code=400, detail=msg) diff --git a/autogpt_platform/backend/backend/server/v2/library/routes_test.py b/autogpt_platform/backend/backend/server/v2/library/routes_test.py index d793ce13b..410f07942 100644 --- a/autogpt_platform/backend/backend/server/v2/library/routes_test.py +++ b/autogpt_platform/backend/backend/server/v2/library/routes_test.py @@ -1,3 +1,5 @@ +import datetime + import autogpt_libs.auth.depends import autogpt_libs.auth.middleware import fastapi @@ -35,21 +37,29 @@ def test_get_library_agents_success(mocker: pytest_mock.MockFixture): mocked_value = [ backend.server.v2.library.model.LibraryAgent( id="test-agent-1", - version=1, - is_active=True, + agent_id="test-agent-1", + agent_version=1, + preset_id="preset-1", + updated_at=datetime.datetime(2023, 1, 1, 0, 0, 0), + is_favorite=False, + is_created_by_user=True, + is_latest_version=True, name="Test Agent 1", description="Test Description 1", - isCreatedByUser=True, input_schema={"type": "object", "properties": {}}, output_schema={"type": "object", "properties": {}}, ), backend.server.v2.library.model.LibraryAgent( id="test-agent-2", - version=1, - is_active=True, + agent_id="test-agent-2", + agent_version=1, + preset_id="preset-2", + updated_at=datetime.datetime(2023, 1, 1, 0, 0, 0), + is_favorite=False, + is_created_by_user=False, + is_latest_version=True, name="Test Agent 2", description="Test Description 2", - isCreatedByUser=False, input_schema={"type": "object", "properties": {}}, output_schema={"type": "object", "properties": {}}, ), @@ -65,10 +75,10 @@ def test_get_library_agents_success(mocker: pytest_mock.MockFixture): for agent in response.json() ] assert len(data) == 2 - assert data[0].id == "test-agent-1" - assert data[0].isCreatedByUser is True - assert data[1].id == "test-agent-2" - assert data[1].isCreatedByUser is False + assert data[0].agent_id == "test-agent-1" + assert data[0].is_created_by_user is True + assert data[1].agent_id == "test-agent-2" + assert data[1].is_created_by_user is False mock_db_call.assert_called_once_with("test-user-id") diff --git a/autogpt_platform/backend/migrations/20250107095812_preset_soft_delete/migration.sql b/autogpt_platform/backend/migrations/20250107095812_preset_soft_delete/migration.sql new file mode 100644 index 000000000..e85cee965 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250107095812_preset_soft_delete/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "AgentPreset" ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT false; diff --git a/autogpt_platform/backend/migrations/20250108084101_user_agent_to_library_agent/migration.sql b/autogpt_platform/backend/migrations/20250108084101_user_agent_to_library_agent/migration.sql new file mode 100644 index 000000000..a822161a1 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250108084101_user_agent_to_library_agent/migration.sql @@ -0,0 +1,46 @@ +/* + Warnings: + + - You are about to drop the `UserAgent` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "UserAgent" DROP CONSTRAINT "UserAgent_agentId_agentVersion_fkey"; + +-- DropForeignKey +ALTER TABLE "UserAgent" DROP CONSTRAINT "UserAgent_agentPresetId_fkey"; + +-- DropForeignKey +ALTER TABLE "UserAgent" DROP CONSTRAINT "UserAgent_userId_fkey"; + +-- DropTable +DROP TABLE "UserAgent"; + +-- CreateTable +CREATE TABLE "LibraryAgent" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "agentVersion" INTEGER NOT NULL, + "agentPresetId" TEXT, + "isFavorite" BOOLEAN NOT NULL DEFAULT false, + "isCreatedByUser" BOOLEAN NOT NULL DEFAULT false, + "isArchived" BOOLEAN NOT NULL DEFAULT false, + "isDeleted" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "LibraryAgent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "LibraryAgent_userId_idx" ON "LibraryAgent"("userId"); + +-- AddForeignKey +ALTER TABLE "LibraryAgent" ADD CONSTRAINT "LibraryAgent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LibraryAgent" ADD CONSTRAINT "LibraryAgent_agentId_agentVersion_fkey" FOREIGN KEY ("agentId", "agentVersion") REFERENCES "AgentGraph"("id", "version") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "LibraryAgent" ADD CONSTRAINT "LibraryAgent_agentPresetId_fkey" FOREIGN KEY ("agentPresetId") REFERENCES "AgentPreset"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/autogpt_platform/backend/migrations/20250108084305_use_graph_is_active_version/migration.sql b/autogpt_platform/backend/migrations/20250108084305_use_graph_is_active_version/migration.sql new file mode 100644 index 000000000..5ffca8202 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250108084305_use_graph_is_active_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LibraryAgent" ADD COLUMN "useGraphIsActiveVersion" BOOLEAN NOT NULL DEFAULT false; diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index 470688aa4..4d219bf8a 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -30,7 +30,7 @@ model User { CreditTransaction CreditTransaction[] AgentPreset AgentPreset[] - UserAgent UserAgent[] + LibraryAgent LibraryAgent[] Profile Profile[] StoreListing StoreListing[] @@ -65,7 +65,7 @@ model AgentGraph { AgentGraphExecution AgentGraphExecution[] AgentPreset AgentPreset[] - UserAgent UserAgent[] + LibraryAgent LibraryAgent[] StoreListing StoreListing[] StoreListingVersion StoreListingVersion? @@ -103,15 +103,17 @@ model AgentPreset { Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version], onDelete: Cascade) InputPresets AgentNodeExecutionInputOutput[] @relation("AgentPresetsInputData") - UserAgents UserAgent[] + LibraryAgents LibraryAgent[] AgentExecution AgentGraphExecution[] + isDeleted Boolean @default(false) + @@index([userId]) } // For the library page // It is a user controlled list of agents, that they will see in there library -model UserAgent { +model LibraryAgent { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@ -126,6 +128,8 @@ model UserAgent { agentPresetId String? AgentPreset AgentPreset? @relation(fields: [agentPresetId], references: [id]) + useGraphIsActiveVersion Boolean @default(false) + isFavorite Boolean @default(false) isCreatedByUser Boolean @default(false) isArchived Boolean @default(false) diff --git a/autogpt_platform/backend/test/executor/test_manager.py b/autogpt_platform/backend/test/executor/test_manager.py index e85712545..456500466 100644 --- a/autogpt_platform/backend/test/executor/test_manager.py +++ b/autogpt_platform/backend/test/executor/test_manager.py @@ -5,8 +5,9 @@ import fastapi.responses import pytest from prisma.models import User +import backend.server.v2.library.model import backend.server.v2.store.model -from backend.blocks.basic import FindInDictionaryBlock, StoreValueBlock +from backend.blocks.basic import AgentInputBlock, FindInDictionaryBlock, StoreValueBlock from backend.blocks.maths import CalculatorBlock, Operation from backend.data import execution, graph from backend.server.model import CreateGraph @@ -292,6 +293,193 @@ async def test_static_input_link_on_graph(server: SpinTestServer): logger.info("Completed test_static_input_link_on_graph") +@pytest.mark.asyncio(scope="session") +async def test_execute_preset(server: SpinTestServer): + """ + Test executing a preset. + + This test ensures that: + 1. A preset can be successfully executed + 2. The execution results are correct + + Args: + server (SpinTestServer): The test server instance. + """ + # Create test graph and user + nodes = [ + graph.Node( # 0 + block_id=AgentInputBlock().id, + input_default={"name": "dictionary"}, + ), + graph.Node( # 1 + block_id=AgentInputBlock().id, + input_default={"name": "selected_value"}, + ), + graph.Node( # 2 + block_id=StoreValueBlock().id, + input_default={"input": {"key1": "Hi", "key2": "Everyone"}}, + ), + graph.Node( # 3 + block_id=FindInDictionaryBlock().id, + input_default={"key": "", "input": {}}, + ), + ] + links = [ + graph.Link( + source_id=nodes[0].id, + sink_id=nodes[2].id, + source_name="result", + sink_name="input", + ), + graph.Link( + source_id=nodes[1].id, + sink_id=nodes[3].id, + source_name="result", + sink_name="key", + ), + graph.Link( + source_id=nodes[2].id, + sink_id=nodes[3].id, + source_name="output", + sink_name="input", + ), + ] + test_graph = graph.Graph( + name="TestGraph", + description="Test graph", + nodes=nodes, + links=links, + ) + test_user = await create_test_user() + test_graph = await create_graph(server, test_graph, test_user) + + # Create preset with initial values + preset = backend.server.v2.library.model.CreateLibraryAgentPresetRequest( + name="Test Preset With Clash", + description="Test preset with clashing input values", + agent_id=test_graph.id, + agent_version=test_graph.version, + inputs={ + "dictionary": {"key1": "Hello", "key2": "World"}, + "selected_value": "key2", + }, + is_active=True, + ) + created_preset = await server.agent_server.test_create_preset(preset, test_user.id) + + # Execute preset with overriding values + result = await server.agent_server.test_execute_preset( + graph_id=test_graph.id, + graph_version=test_graph.version, + preset_id=created_preset.id, + node_input={}, + user_id=test_user.id, + ) + + # Verify execution + assert result is not None + graph_exec_id = result["id"] + + # Wait for execution to complete + executions = await wait_execution(test_user.id, test_graph.id, graph_exec_id) + assert len(executions) == 4 + + # FindInDictionaryBlock should wait for the input pin to be provided, + # Hence executing extraction of "key" from {"key1": "value1", "key2": "value2"} + assert executions[3].status == execution.ExecutionStatus.COMPLETED + assert executions[3].output_data == {"output": ["World"]} + + +@pytest.mark.asyncio(scope="session") +async def test_execute_preset_with_clash(server: SpinTestServer): + """ + Test executing a preset with clashing input data. + """ + # Create test graph and user + nodes = [ + graph.Node( # 0 + block_id=AgentInputBlock().id, + input_default={"name": "dictionary"}, + ), + graph.Node( # 1 + block_id=AgentInputBlock().id, + input_default={"name": "selected_value"}, + ), + graph.Node( # 2 + block_id=StoreValueBlock().id, + input_default={"input": {"key1": "Hi", "key2": "Everyone"}}, + ), + graph.Node( # 3 + block_id=FindInDictionaryBlock().id, + input_default={"key": "", "input": {}}, + ), + ] + links = [ + graph.Link( + source_id=nodes[0].id, + sink_id=nodes[2].id, + source_name="result", + sink_name="input", + ), + graph.Link( + source_id=nodes[1].id, + sink_id=nodes[3].id, + source_name="result", + sink_name="key", + ), + graph.Link( + source_id=nodes[2].id, + sink_id=nodes[3].id, + source_name="output", + sink_name="input", + ), + ] + test_graph = graph.Graph( + name="TestGraph", + description="Test graph", + nodes=nodes, + links=links, + ) + test_user = await create_test_user() + test_graph = await create_graph(server, test_graph, test_user) + + # Create preset with initial values + preset = backend.server.v2.library.model.CreateLibraryAgentPresetRequest( + name="Test Preset With Clash", + description="Test preset with clashing input values", + agent_id=test_graph.id, + agent_version=test_graph.version, + inputs={ + "dictionary": {"key1": "Hello", "key2": "World"}, + "selected_value": "key2", + }, + is_active=True, + ) + created_preset = await server.agent_server.test_create_preset(preset, test_user.id) + + # Execute preset with overriding values + result = await server.agent_server.test_execute_preset( + graph_id=test_graph.id, + graph_version=test_graph.version, + preset_id=created_preset.id, + node_input={"selected_value": "key1"}, + user_id=test_user.id, + ) + + # Verify execution + assert result is not None + graph_exec_id = result["id"] + + # Wait for execution to complete + executions = await wait_execution(test_user.id, test_graph.id, graph_exec_id) + assert len(executions) == 4 + + # FindInDictionaryBlock should wait for the input pin to be provided, + # Hence executing extraction of "key" from {"key1": "value1", "key2": "value2"} + assert executions[3].status == execution.ExecutionStatus.COMPLETED + assert executions[3].output_data == {"output": ["Hello"]} + + @pytest.mark.asyncio(scope="session") async def test_store_listing_graph(server: SpinTestServer): logger.info("Starting test_agent_execution") diff --git a/autogpt_platform/backend/test/test_data_creator.py b/autogpt_platform/backend/test/test_data_creator.py index 1f79386cc..3ae581892 100644 --- a/autogpt_platform/backend/test/test_data_creator.py +++ b/autogpt_platform/backend/test/test_data_creator.py @@ -140,10 +140,10 @@ async def main(): print(f"Inserting {NUM_USERS * MAX_AGENTS_PER_USER} user agents") for user in users: num_agents = random.randint(MIN_AGENTS_PER_USER, MAX_AGENTS_PER_USER) - for _ in range(num_agents): # Create 1 UserAgent per user + for _ in range(num_agents): # Create 1 LibraryAgent per user graph = random.choice(agent_graphs) preset = random.choice(agent_presets) - user_agent = await db.useragent.create( + user_agent = await db.libraryagent.create( data={ "userId": user.id, "agentId": graph.id,