diff --git a/autogpt_platform/CLAUDE.md b/autogpt_platform/CLAUDE.md index 94c835dd56..d6f69d6799 100644 --- a/autogpt_platform/CLAUDE.md +++ b/autogpt_platform/CLAUDE.md @@ -156,7 +156,7 @@ Key models (defined in `/backend/schema.prisma`): 5. Register in block registry 6. Generate the block uuid using `uuid.uuid4()` -Note: when making many new blocks analyze the interfaces for each of these blcoks and picture if they would go well together in a graph based editor or would they struggle to connect productively? +Note: when making many new blocks analyze the interfaces for each of these blocks and picture if they would go well together in a graph based editor or would they struggle to connect productively? ex: do the inputs and outputs tie well together? **Modifying the API:** diff --git a/autogpt_platform/backend/backend/blocks/system/__init__.py b/autogpt_platform/backend/backend/blocks/system/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/system/__init__.py @@ -0,0 +1 @@ + diff --git a/autogpt_platform/backend/backend/blocks/system/library_operations.py b/autogpt_platform/backend/backend/blocks/system/library_operations.py new file mode 100644 index 0000000000..2cf1aec0db --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/system/library_operations.py @@ -0,0 +1,283 @@ +import logging +from typing import Any + +from pydantic import BaseModel + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField +from backend.util.clients import get_database_manager_async_client + +logger = logging.getLogger(__name__) + + +# Duplicate pydantic models for store data so we don't accidently change the data shape in the blocks unintentionally when editing the backend +class LibraryAgent(BaseModel): + """Model representing an agent in the user's library.""" + + library_agent_id: str = "" + agent_id: str = "" + agent_version: int = 0 + agent_name: str = "" + description: str = "" + creator: str = "" + is_archived: bool = False + categories: list[str] = [] + + +class AddToLibraryFromStoreBlock(Block): + """ + Block that adds an agent from the store to the user's library. + This enables users to easily import agents from the marketplace into their personal collection. + """ + + class Input(BlockSchema): + store_listing_version_id: str = SchemaField( + description="The ID of the store listing version to add to library" + ) + agent_name: str | None = SchemaField( + description="Optional custom name for the agent in your library", + default=None, + ) + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the agent was successfully added to library" + ) + library_agent_id: str = SchemaField( + description="The ID of the library agent entry" + ) + agent_id: str = SchemaField(description="The ID of the agent graph") + agent_version: int = SchemaField( + description="The version number of the agent graph" + ) + agent_name: str = SchemaField(description="The name of the agent") + message: str = SchemaField(description="Success or error message") + + def __init__(self): + super().__init__( + id="2602a7b1-3f4d-4e5f-9c8b-1a2b3c4d5e6f", + description="Add an agent from the store to your personal library", + categories={BlockCategory.BASIC}, + input_schema=AddToLibraryFromStoreBlock.Input, + output_schema=AddToLibraryFromStoreBlock.Output, + test_input={ + "store_listing_version_id": "test-listing-id", + "agent_name": "My Custom Agent", + }, + test_output=[ + ("success", True), + ("library_agent_id", "test-library-id"), + ("agent_id", "test-agent-id"), + ("agent_version", 1), + ("agent_name", "Test Agent"), + ("message", "Agent successfully added to library"), + ], + test_mock={ + "_add_to_library": lambda *_, **__: LibraryAgent( + library_agent_id="test-library-id", + agent_id="test-agent-id", + agent_version=1, + agent_name="Test Agent", + ) + }, + ) + + async def run( + self, + input_data: Input, + *, + user_id: str, + **kwargs, + ) -> BlockOutput: + library_agent = await self._add_to_library( + user_id=user_id, + store_listing_version_id=input_data.store_listing_version_id, + custom_name=input_data.agent_name, + ) + + yield "success", True + yield "library_agent_id", library_agent.library_agent_id + yield "agent_id", library_agent.agent_id + yield "agent_version", library_agent.agent_version + yield "agent_name", library_agent.agent_name + yield "message", "Agent successfully added to library" + + async def _add_to_library( + self, + user_id: str, + store_listing_version_id: str, + custom_name: str | None = None, + ) -> LibraryAgent: + """ + Add a store agent to the user's library using the existing library database function. + """ + library_agent = ( + await get_database_manager_async_client().add_store_agent_to_library( + store_listing_version_id=store_listing_version_id, user_id=user_id + ) + ) + + # If custom name is provided, we could update the library agent name here + # For now, we'll just return the agent info + agent_name = custom_name if custom_name else library_agent.name + + return LibraryAgent( + library_agent_id=library_agent.id, + agent_id=library_agent.graph_id, + agent_version=library_agent.graph_version, + agent_name=agent_name, + ) + + +class ListLibraryAgentsBlock(Block): + """ + Block that lists all agents in the user's library. + """ + + class Input(BlockSchema): + search_query: str | None = SchemaField( + description="Optional search query to filter agents", default=None + ) + limit: int = SchemaField( + description="Maximum number of agents to return", default=50, ge=1, le=100 + ) + page: int = SchemaField( + description="Page number for pagination", default=1, ge=1 + ) + + class Output(BlockSchema): + agents: list[LibraryAgent] = SchemaField( + description="List of agents in the library", + default_factory=list, + ) + agent: LibraryAgent = SchemaField( + description="Individual library agent (yielded for each agent)" + ) + total_count: int = SchemaField( + description="Total number of agents in library", default=0 + ) + page: int = SchemaField(description="Current page number", default=1) + total_pages: int = SchemaField(description="Total number of pages", default=1) + + def __init__(self): + super().__init__( + id="082602d3-a74d-4600-9e9c-15b3af7eae98", + description="List all agents in your personal library", + categories={BlockCategory.BASIC, BlockCategory.DATA}, + input_schema=ListLibraryAgentsBlock.Input, + output_schema=ListLibraryAgentsBlock.Output, + test_input={ + "search_query": None, + "limit": 10, + "page": 1, + }, + test_output=[ + ( + "agents", + [ + LibraryAgent( + library_agent_id="test-lib-id", + agent_id="test-agent-id", + agent_version=1, + agent_name="Test Library Agent", + description="A test agent in library", + creator="Test User", + ), + ], + ), + ("total_count", 1), + ("page", 1), + ("total_pages", 1), + ( + "agent", + LibraryAgent( + library_agent_id="test-lib-id", + agent_id="test-agent-id", + agent_version=1, + agent_name="Test Library Agent", + description="A test agent in library", + creator="Test User", + ), + ), + ], + test_mock={ + "_list_library_agents": lambda *_, **__: { + "agents": [ + LibraryAgent( + library_agent_id="test-lib-id", + agent_id="test-agent-id", + agent_version=1, + agent_name="Test Library Agent", + description="A test agent in library", + creator="Test User", + ) + ], + "total": 1, + "page": 1, + "total_pages": 1, + } + }, + ) + + async def run( + self, + input_data: Input, + *, + user_id: str, + **kwargs, + ) -> BlockOutput: + result = await self._list_library_agents( + user_id=user_id, + search_query=input_data.search_query, + limit=input_data.limit, + page=input_data.page, + ) + + agents = result["agents"] + + yield "agents", agents + yield "total_count", result["total"] + yield "page", result["page"] + yield "total_pages", result["total_pages"] + + # Yield each agent individually for better graph connectivity + for agent in agents: + yield "agent", agent + + async def _list_library_agents( + self, + user_id: str, + search_query: str | None = None, + limit: int = 50, + page: int = 1, + ) -> dict[str, Any]: + """ + List agents in the user's library using the database client. + """ + result = await get_database_manager_async_client().list_library_agents( + user_id=user_id, + search_term=search_query, + page=page, + page_size=limit, + ) + + agents = [ + LibraryAgent( + library_agent_id=agent.id, + agent_id=agent.graph_id, + agent_version=agent.graph_version, + agent_name=agent.name, + description=getattr(agent, "description", ""), + creator=getattr(agent, "creator", ""), + is_archived=getattr(agent, "is_archived", False), + categories=getattr(agent, "categories", []), + ) + for agent in result.agents + ] + + return { + "agents": agents, + "total": result.pagination.total_items, + "page": result.pagination.current_page, + "total_pages": result.pagination.total_pages, + } diff --git a/autogpt_platform/backend/backend/blocks/system/store_operations.py b/autogpt_platform/backend/backend/blocks/system/store_operations.py new file mode 100644 index 0000000000..bef6eb610e --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/system/store_operations.py @@ -0,0 +1,311 @@ +import logging +from typing import Literal + +from pydantic import BaseModel + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField +from backend.util.clients import get_database_manager_async_client + +logger = logging.getLogger(__name__) + + +# Duplicate pydantic models for store data so we don't accidently change the data shape in the blocks unintentionally when editing the backend +class StoreAgent(BaseModel): + """Model representing a store agent.""" + + slug: str = "" + name: str = "" + description: str = "" + creator: str = "" + rating: float = 0.0 + runs: int = 0 + categories: list[str] = [] + + +class StoreAgentDict(BaseModel): + """Dictionary representation of a store agent.""" + + slug: str + name: str + description: str + creator: str + rating: float + runs: int + + +class SearchAgentsResponse(BaseModel): + """Response from searching store agents.""" + + agents: list[StoreAgentDict] + total_count: int + + +class StoreAgentDetails(BaseModel): + """Detailed information about a store agent.""" + + found: bool + store_listing_version_id: str = "" + agent_name: str = "" + description: str = "" + creator: str = "" + categories: list[str] = [] + runs: int = 0 + rating: float = 0.0 + + +class GetStoreAgentDetailsBlock(Block): + """ + Block that retrieves detailed information about an agent from the store. + """ + + class Input(BlockSchema): + creator: str = SchemaField(description="The username of the agent creator") + slug: str = SchemaField(description="The name of the agent") + + class Output(BlockSchema): + found: bool = SchemaField( + description="Whether the agent was found in the store" + ) + store_listing_version_id: str = SchemaField( + description="The store listing version ID" + ) + agent_name: str = SchemaField(description="Name of the agent") + description: str = SchemaField(description="Description of the agent") + creator: str = SchemaField(description="Creator of the agent") + categories: list[str] = SchemaField( + description="Categories the agent belongs to", default_factory=list + ) + runs: int = SchemaField( + description="Number of times the agent has been run", default=0 + ) + rating: float = SchemaField( + description="Average rating of the agent", default=0.0 + ) + + def __init__(self): + super().__init__( + id="b604f0ec-6e0d-40a7-bf55-9fd09997cced", + description="Get detailed information about an agent from the store", + categories={BlockCategory.BASIC, BlockCategory.DATA}, + input_schema=GetStoreAgentDetailsBlock.Input, + output_schema=GetStoreAgentDetailsBlock.Output, + test_input={"creator": "test-creator", "slug": "test-agent-slug"}, + test_output=[ + ("found", True), + ("store_listing_version_id", "test-listing-id"), + ("agent_name", "Test Agent"), + ("description", "A test agent"), + ("creator", "Test Creator"), + ("categories", ["productivity", "automation"]), + ("runs", 100), + ("rating", 4.5), + ], + test_mock={ + "_get_agent_details": lambda *_, **__: StoreAgentDetails( + found=True, + store_listing_version_id="test-listing-id", + agent_name="Test Agent", + description="A test agent", + creator="Test Creator", + categories=["productivity", "automation"], + runs=100, + rating=4.5, + ) + }, + static_output=True, + ) + + async def run( + self, + input_data: Input, + **kwargs, + ) -> BlockOutput: + details = await self._get_agent_details( + creator=input_data.creator, slug=input_data.slug + ) + yield "found", details.found + yield "store_listing_version_id", details.store_listing_version_id + yield "agent_name", details.agent_name + yield "description", details.description + yield "creator", details.creator + yield "categories", details.categories + yield "runs", details.runs + yield "rating", details.rating + + async def _get_agent_details(self, creator: str, slug: str) -> StoreAgentDetails: + """ + Retrieve detailed information about a store agent. + """ + # Get by specific version ID + agent_details = ( + await get_database_manager_async_client().get_store_agent_details( + username=creator, agent_name=slug + ) + ) + + return StoreAgentDetails( + found=True, + store_listing_version_id=agent_details.store_listing_version_id, + agent_name=agent_details.agent_name, + description=agent_details.description, + creator=agent_details.creator, + categories=( + agent_details.categories if hasattr(agent_details, "categories") else [] + ), + runs=agent_details.runs, + rating=agent_details.rating, + ) + + +class SearchStoreAgentsBlock(Block): + """ + Block that searches for agents in the store based on various criteria. + """ + + class Input(BlockSchema): + query: str | None = SchemaField( + description="Search query to find agents", default=None + ) + category: str | None = SchemaField( + description="Filter by category", default=None + ) + sort_by: Literal["rating", "runs", "name", "recent"] = SchemaField( + description="How to sort the results", default="rating" + ) + limit: int = SchemaField( + description="Maximum number of results to return", default=10, ge=1, le=100 + ) + + class Output(BlockSchema): + agents: list[StoreAgent] = SchemaField( + description="List of agents matching the search criteria", + default_factory=list, + ) + agent: StoreAgent = SchemaField(description="Basic information of the agent") + total_count: int = SchemaField( + description="Total number of agents found", default=0 + ) + + def __init__(self): + super().__init__( + id="39524701-026c-4328-87cc-1b88c8e2cb4c", + description="Search for agents in the store", + categories={BlockCategory.BASIC, BlockCategory.DATA}, + input_schema=SearchStoreAgentsBlock.Input, + output_schema=SearchStoreAgentsBlock.Output, + test_input={ + "query": "productivity", + "category": None, + "sort_by": "rating", + "limit": 10, + }, + test_output=[ + ( + "agents", + [ + { + "slug": "test-agent", + "name": "Test Agent", + "description": "A test agent", + "creator": "Test Creator", + "rating": 4.5, + "runs": 100, + } + ], + ), + ("total_count", 1), + ( + "agent", + { + "slug": "test-agent", + "name": "Test Agent", + "description": "A test agent", + "creator": "Test Creator", + "rating": 4.5, + "runs": 100, + }, + ), + ], + test_mock={ + "_search_agents": lambda *_, **__: SearchAgentsResponse( + agents=[ + StoreAgentDict( + slug="test-agent", + name="Test Agent", + description="A test agent", + creator="Test Creator", + rating=4.5, + runs=100, + ) + ], + total_count=1, + ) + }, + ) + + async def run( + self, + input_data: Input, + **kwargs, + ) -> BlockOutput: + result = await self._search_agents( + query=input_data.query, + category=input_data.category, + sort_by=input_data.sort_by, + limit=input_data.limit, + ) + + agents = result.agents + total_count = result.total_count + + # Convert to dict for output + agents_as_dicts = [agent.model_dump() for agent in agents] + + yield "agents", agents_as_dicts + yield "total_count", total_count + + for agent_dict in agents_as_dicts: + yield "agent", agent_dict + + async def _search_agents( + self, + query: str | None = None, + category: str | None = None, + sort_by: str = "rating", + limit: int = 10, + ) -> SearchAgentsResponse: + """ + Search for agents in the store using the existing store database function. + """ + # Map our sort_by to the store's sorted_by parameter + sorted_by_map = { + "rating": "most_popular", + "runs": "most_runs", + "name": "alphabetical", + "recent": "recently_updated", + } + + result = await get_database_manager_async_client().get_store_agents( + featured=False, + creator=None, + sorted_by=sorted_by_map.get(sort_by, "most_popular"), + search_query=query, + category=category, + page=1, + page_size=limit, + ) + + agents = [ + StoreAgentDict( + slug=agent.slug, + name=agent.agent_name, + description=agent.description, + creator=agent.creator, + rating=agent.rating, + runs=agent.runs, + ) + for agent in result.agents + ] + + return SearchAgentsResponse(agents=agents, total_count=len(agents)) diff --git a/autogpt_platform/backend/backend/blocks/test/test_store_operations.py b/autogpt_platform/backend/backend/blocks/test/test_store_operations.py new file mode 100644 index 0000000000..088d0d60e5 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/test/test_store_operations.py @@ -0,0 +1,155 @@ +from unittest.mock import MagicMock + +import pytest + +from backend.blocks.system.library_operations import ( + AddToLibraryFromStoreBlock, + LibraryAgent, +) +from backend.blocks.system.store_operations import ( + GetStoreAgentDetailsBlock, + SearchAgentsResponse, + SearchStoreAgentsBlock, + StoreAgentDetails, + StoreAgentDict, +) + + +@pytest.mark.asyncio +async def test_add_to_library_from_store_block_success(mocker): + """Test successful addition of agent from store to library.""" + block = AddToLibraryFromStoreBlock() + + # Mock the library agent response + mock_library_agent = MagicMock() + mock_library_agent.id = "lib-agent-123" + mock_library_agent.graph_id = "graph-456" + mock_library_agent.graph_version = 1 + mock_library_agent.name = "Test Agent" + + mocker.patch.object( + block, + "_add_to_library", + return_value=LibraryAgent( + library_agent_id="lib-agent-123", + agent_id="graph-456", + agent_version=1, + agent_name="Test Agent", + ), + ) + + input_data = block.Input( + store_listing_version_id="store-listing-v1", agent_name="Custom Agent Name" + ) + + outputs = {} + async for name, value in block.run(input_data, user_id="test-user"): + outputs[name] = value + + assert outputs["success"] is True + assert outputs["library_agent_id"] == "lib-agent-123" + assert outputs["agent_id"] == "graph-456" + assert outputs["agent_version"] == 1 + assert outputs["agent_name"] == "Test Agent" + assert outputs["message"] == "Agent successfully added to library" + + +@pytest.mark.asyncio +async def test_get_store_agent_details_block_success(mocker): + """Test successful retrieval of store agent details.""" + block = GetStoreAgentDetailsBlock() + + mocker.patch.object( + block, + "_get_agent_details", + return_value=StoreAgentDetails( + found=True, + store_listing_version_id="version-123", + agent_name="Test Agent", + description="A test agent for testing", + creator="Test Creator", + categories=["productivity", "automation"], + runs=100, + rating=4.5, + ), + ) + + input_data = block.Input(creator="Test Creator", slug="test-slug") + outputs = {} + async for name, value in block.run(input_data): + outputs[name] = value + + assert outputs["found"] is True + assert outputs["store_listing_version_id"] == "version-123" + assert outputs["agent_name"] == "Test Agent" + assert outputs["description"] == "A test agent for testing" + assert outputs["creator"] == "Test Creator" + assert outputs["categories"] == ["productivity", "automation"] + assert outputs["runs"] == 100 + assert outputs["rating"] == 4.5 + + +@pytest.mark.asyncio +async def test_search_store_agents_block(mocker): + """Test searching for store agents.""" + block = SearchStoreAgentsBlock() + + mocker.patch.object( + block, + "_search_agents", + return_value=SearchAgentsResponse( + agents=[ + StoreAgentDict( + slug="creator1/agent1", + name="Agent One", + description="First test agent", + creator="Creator 1", + rating=4.8, + runs=500, + ), + StoreAgentDict( + slug="creator2/agent2", + name="Agent Two", + description="Second test agent", + creator="Creator 2", + rating=4.2, + runs=200, + ), + ], + total_count=2, + ), + ) + + input_data = block.Input( + query="test", category="productivity", sort_by="rating", limit=10 + ) + + outputs = {} + async for name, value in block.run(input_data): + outputs[name] = value + + assert len(outputs["agents"]) == 2 + assert outputs["total_count"] == 2 + assert outputs["agents"][0]["name"] == "Agent One" + assert outputs["agents"][0]["rating"] == 4.8 + + +@pytest.mark.asyncio +async def test_search_store_agents_block_empty_results(mocker): + """Test searching with no results.""" + block = SearchStoreAgentsBlock() + + mocker.patch.object( + block, + "_search_agents", + return_value=SearchAgentsResponse(agents=[], total_count=0), + ) + + input_data = block.Input(query="nonexistent", limit=10) + + outputs = {} + async for name, value in block.run(input_data): + outputs[name] = value + + assert outputs["agents"] == [] + assert outputs["total_count"] == 0 diff --git a/autogpt_platform/backend/backend/executor/database.py b/autogpt_platform/backend/backend/executor/database.py index e59d03bb5e..e7ee2dc661 100644 --- a/autogpt_platform/backend/backend/executor/database.py +++ b/autogpt_platform/backend/backend/executor/database.py @@ -42,6 +42,8 @@ from backend.data.user import ( get_user_notification_preference, update_user_integrations, ) +from backend.server.v2.library.db import add_store_agent_to_library, list_library_agents +from backend.server.v2.store.db import get_store_agent_details, get_store_agents from backend.util.service import ( AppService, AppServiceClient, @@ -145,6 +147,14 @@ class DatabaseManager(AppService): get_user_notification_oldest_message_in_batch ) + # Library + list_library_agents = _(list_library_agents) + add_store_agent_to_library = _(add_store_agent_to_library) + + # Store + get_store_agents = _(get_store_agents) + get_store_agent_details = _(get_store_agent_details) + # Summary data - async get_user_execution_summary_data = _(get_user_execution_summary_data) @@ -179,6 +189,14 @@ class DatabaseManagerClient(AppServiceClient): # Block error monitoring get_block_error_stats = _(d.get_block_error_stats) + # Library + list_library_agents = _(d.list_library_agents) + add_store_agent_to_library = _(d.add_store_agent_to_library) + + # Store + get_store_agents = _(d.get_store_agents) + get_store_agent_details = _(d.get_store_agent_details) + class DatabaseManagerAsyncClient(AppServiceClient): d = DatabaseManager @@ -223,5 +241,13 @@ class DatabaseManagerAsyncClient(AppServiceClient): d.get_user_notification_oldest_message_in_batch ) + # Library + list_library_agents = d.list_library_agents + add_store_agent_to_library = d.add_store_agent_to_library + + # Store + get_store_agents = d.get_store_agents + get_store_agent_details = d.get_store_agent_details + # Summary data get_user_execution_summary_data = d.get_user_execution_summary_data