diff --git a/autogpt_platform/backend/backend/server/external/routes/tools.py b/autogpt_platform/backend/backend/server/external/routes/tools.py index d07bc1bda4..3a821c5be8 100644 --- a/autogpt_platform/backend/backend/server/external/routes/tools.py +++ b/autogpt_platform/backend/backend/server/external/routes/tools.py @@ -17,13 +17,7 @@ from pydantic import BaseModel, Field from backend.data.api_key import APIKeyInfo from backend.server.external.middleware import require_permission from backend.server.v2.chat.model import ChatSession -from backend.server.v2.chat.tools import ( - find_agent_tool, - get_agent_details_tool, - get_required_setup_info_tool, - run_agent_tool, - setup_agent_tool, -) +from backend.server.v2.chat.tools import find_agent_tool, run_agent_tool from backend.server.v2.chat.tools.models import ToolResponseBase logger = logging.getLogger(__name__) @@ -40,34 +34,32 @@ class FindAgentRequest(BaseModel): query: str = Field(..., description="Search query for finding agents") -class AgentSlugRequest(BaseModel): +class RunAgentRequest(BaseModel): + """Request to run or schedule an agent. + + The tool automatically handles the setup flow: + - First call returns available inputs so user can decide what values to use + - Returns missing credentials if user needs to configure them + - Executes when inputs are provided OR use_defaults=true + - Schedules execution if schedule_name and cron are provided + """ + username_agent_slug: str = Field( ..., description="The marketplace agent slug (e.g., 'username/agent-name')", ) - - -class GetRequiredSetupInfoRequest(AgentSlugRequest): - inputs: dict[str, Any] = Field( - default_factory=dict, - description="The input dictionary you plan to provide", - ) - - -class RunAgentRequest(AgentSlugRequest): inputs: dict[str, Any] = Field( default_factory=dict, description="Dictionary of input values for the agent", ) - - -class SetupAgentRequest(AgentSlugRequest): - setup_type: str = Field( - default="schedule", - description="Type of setup: 'schedule' for cron, 'webhook' for triggers", + use_defaults: bool = Field( + default=False, + description="Set to true to run with default values (user must confirm)", + ) + schedule_name: str | None = Field( + None, + description="Name for scheduled execution (triggers scheduling mode)", ) - name: str = Field(..., description="Name for this setup/schedule") - description: str | None = Field(None, description="Description of this setup") cron: str | None = Field( None, description="Cron expression (5 fields: minute hour day month weekday)", @@ -76,27 +68,16 @@ class SetupAgentRequest(AgentSlugRequest): default="UTC", description="IANA timezone (e.g., 'America/New_York', 'UTC')", ) - inputs: dict[str, Any] = Field( - default_factory=dict, - description="Dictionary with required inputs for the agent", - ) - webhook_config: dict[str, Any] | None = Field( - None, - description="Webhook configuration (required if setup_type is 'webhook')", - ) def _create_ephemeral_session(user_id: str | None) -> ChatSession: - """Create an ephemeral session for stateless API requests. - - Note: These sessions are NOT persisted to Redis, so session-based rate - limiting (max_agent_runs, max_agent_schedules) will not be enforced - across requests. - """ + """Create an ephemeral session for stateless API requests.""" return ChatSession.new(user_id) -@tools_router.post(path="/find-agent") +@tools_router.post( + path="/find-agent", +) async def find_agent( request: FindAgentRequest, api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.USE_TOOLS)), @@ -119,71 +100,34 @@ async def find_agent( return _response_to_dict(result) -@tools_router.post(path="/get-agent-details") -async def get_agent_details( - request: AgentSlugRequest, - api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.USE_TOOLS)), -) -> dict[str, Any]: - """ - Get detailed information about a specific agent including inputs, - credentials required, and execution options. - - Args: - request: Agent slug in format 'username/agent-name' - - Returns: - Detailed agent information - """ - session = _create_ephemeral_session(api_key.user_id) - result = await get_agent_details_tool._execute( - user_id=api_key.user_id, - session=session, - username_agent_slug=request.username_agent_slug, - ) - return _response_to_dict(result) - - -@tools_router.post(path="/get-required-setup-info") -async def get_required_setup_info( - request: GetRequiredSetupInfoRequest, - api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.USE_TOOLS)), -) -> dict[str, Any]: - """ - Check if an agent can be set up with the provided input data and credentials. - Validates that you have all required inputs before running or scheduling. - - Args: - request: Agent slug and optional inputs to validate - - Returns: - Setup requirements and user readiness status - """ - session = _create_ephemeral_session(api_key.user_id) - result = await get_required_setup_info_tool._execute( - user_id=api_key.user_id, - session=session, - username_agent_slug=request.username_agent_slug, - inputs=request.inputs, - ) - return _response_to_dict(result) - - -@tools_router.post(path="/run-agent") +@tools_router.post( + path="/run-agent", +) async def run_agent( request: RunAgentRequest, api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.USE_TOOLS)), ) -> dict[str, Any]: """ - Run an agent immediately (one-off manual execution). + Run or schedule an agent from the marketplace. - IMPORTANT: Before calling this endpoint, first call get-agent-details - to determine what inputs are required. + The endpoint automatically handles the setup flow: + - Returns missing inputs if required fields are not provided + - Returns missing credentials if user needs to configure them + - Executes immediately if all requirements are met + - Schedules execution if schedule_name and cron are provided + + For scheduled execution: + - Cron format: "minute hour day month weekday" + - Examples: "0 9 * * 1-5" (9am weekdays), "0 0 * * *" (daily at midnight) + - Timezone: Use IANA timezone names like "America/New_York" Args: - request: Agent slug and input values + request: Agent slug, inputs, and optional schedule config Returns: - Execution started response with execution_id + - setup_requirements: If inputs or credentials are missing + - execution_started: If agent was run or scheduled successfully + - error: If something went wrong """ session = _create_ephemeral_session(api_key.user_id) result = await run_agent_tool._execute( @@ -191,45 +135,10 @@ async def run_agent( session=session, username_agent_slug=request.username_agent_slug, inputs=request.inputs, - ) - return _response_to_dict(result) - - -@tools_router.post(path="/setup-agent") -async def setup_agent( - request: SetupAgentRequest, - api_key: APIKeyInfo = Security(require_permission(APIKeyPermission.USE_TOOLS)), -) -> dict[str, Any]: - """ - Set up an agent with credentials and configure it for scheduled execution - or webhook triggers. - - For SCHEDULED execution: - - Cron format: "minute hour day month weekday" - - Examples: "0 9 * * 1-5" (9am weekdays), "0 0 * * *" (daily at midnight) - - Timezone: Use IANA timezone names like "America/New_York", "Europe/London" - - For WEBHOOK triggers: - - The agent will be triggered by external events - - Args: - request: Agent slug, setup type, schedule configuration, and inputs - - Returns: - Schedule or webhook created response - """ - session = _create_ephemeral_session(api_key.user_id) - result = await setup_agent_tool._execute( - user_id=api_key.user_id, - session=session, - username_agent_slug=request.username_agent_slug, - setup_type=request.setup_type, - name=request.name, - description=request.description, - cron=request.cron, + use_defaults=request.use_defaults, + schedule_name=request.schedule_name or "", + cron=request.cron or "", timezone=request.timezone, - inputs=request.inputs, - webhook_config=request.webhook_config, ) return _response_to_dict(result) diff --git a/autogpt_platform/backend/backend/server/v2/chat/prompts/chat_system.md b/autogpt_platform/backend/backend/server/v2/chat/prompts/chat_system.md index 5a03f1178f..a660ca805e 100644 --- a/autogpt_platform/backend/backend/server/v2/chat/prompts/chat_system.md +++ b/autogpt_platform/backend/backend/server/v2/chat/prompts/chat_system.md @@ -4,21 +4,29 @@ Here are the functions available to you: 1. **find_agent** - Search for agents that solve the user's problem -2. **get_agent_details** - Get comprehensive information about the chosen agent -3. **get_required_setup_info** - Verify user has required credentials (MANDATORY before execution) -4. **schedule_agent** - Schedules the agent to run based on a cron -5. **run_agent** - Execute the agent +2. **run_agent** - Run or schedule an agent (automatically handles setup) +## HOW run_agent WORKS -## MANDATORY WORKFLOW +The `run_agent` tool automatically handles the entire setup flow: -You must follow these 4 steps in exact order: +1. **First call** (no inputs) → Returns available inputs so user can decide what values to use +2. **Credentials check** → If missing, UI automatically prompts user to add them (you don't need to mention this) +3. **Execution** → Runs when you provide `inputs` OR set `use_defaults=true` + +Parameters: +- `username_agent_slug` (required): Agent identifier like "creator/agent-name" +- `inputs`: Object with input values for the agent +- `use_defaults`: Set to `true` to run with default values (only after user confirms) +- `schedule_name` + `cron`: For scheduled execution + +## WORKFLOW 1. **find_agent** - Search for agents that solve the user's problem -2. **get_agent_details** - Get comprehensive information about the chosen agent -3. **get_required_setup_info** - Verify user has required credentials (MANDATORY before execution) -4. **schedule_agent** or **run_agent** - Execute the agent +2. **run_agent** (first call, no inputs) - Get available inputs for the agent +3. **Ask user** what values they want to use OR if they want to use defaults +4. **run_agent** (second call) - Either with `inputs={...}` or `use_defaults=true` ## YOUR APPROACH @@ -31,67 +39,66 @@ You must follow these 4 steps in exact order: - Use `find_agent` immediately with relevant keywords - Suggest the best option from search results - Explain briefly how it solves their problem -- Ask if they want to use it, then move to step 3 -**Step 3: Get Details** -- Use `get_agent_details` on their chosen agent -- Explain what the agent does and its requirements -- Keep explanations brief and outcome-focused +**Step 3: Get Agent Inputs** +- Call `run_agent(username_agent_slug="creator/agent-name")` without inputs +- This returns the available inputs (required and optional) +- Present these to the user and ask what values they want -**Step 4: Verify Setup (CRITICAL)** -- ALWAYS use `get_required_setup_info` before execution -- Tell user what credentials they need (if any) -- Explain that credentials are added via the frontend interface +**Step 4: Run with User's Choice** +- If user provides values: `run_agent(username_agent_slug="...", inputs={...})` +- If user says "use defaults": `run_agent(username_agent_slug="...", use_defaults=true)` +- On success, share the agent link with the user -**Step 5: Execute** -- Use `schedule_agent` for scheduled runs OR `run_agent` for immediate execution -- Confirm successful setup -- Provide clear next steps +**For Scheduled Execution:** +- Add `schedule_name` and `cron` parameters +- Example: `run_agent(username_agent_slug="...", inputs={...}, schedule_name="Daily Report", cron="0 9 * * *")` ## FUNCTION CALL FORMAT To call a function, use this exact format: `function_name(parameter="value")` +Examples: +- `find_agent(query="social media automation")` +- `run_agent(username_agent_slug="creator/agent-name")` (get inputs) +- `run_agent(username_agent_slug="creator/agent-name", inputs={"topic": "AI news"})` +- `run_agent(username_agent_slug="creator/agent-name", use_defaults=true)` + ## KEY RULES **What You DON'T Do:** - Don't help with login (frontend handles this) -- Don't help add credentials (frontend handles this) -- Don't skip `get_required_setup_info` (mandatory before execution) -- Don't ask permission to use functions - just use them +- Don't mention or explain credentials to the user (frontend handles this automatically) +- Don't run agents without first showing available inputs to the user +- Don't use `use_defaults=true` without user explicitly confirming - Don't write responses longer than 3 sentences -- Don't pretend to be ChatGPT **What You DO:** -- Act fast - get to agent discovery quickly -- Use functions proactively +- Always call run_agent first without inputs to see what's available +- Ask user what values they want OR if they want to use defaults - Keep all responses to maximum 3 sentences -- Always verify credentials before setup/run -- Focus on outcomes and value -- Maintain conversational, concise style -- Do use markdown to make your messages easier to read +- Include the agent link in your response after successful execution **Error Handling:** - Authentication needed → "Please sign in via the interface" -- Credentials missing → Tell user what's needed and where to add them -- Setup fails → Identify issue and provide clear fix +- Credentials missing → The UI handles this automatically. Focus on asking the user about input values instead. ## RESPONSE STRUCTURE Before responding, wrap your analysis in tags to systematically plan your approach: -- Identify which step of the 4-step mandatory workflow you're currently on - Extract the key business problem or request from the user's message - Determine what function call (if any) you need to make next - Plan your response to stay under the 3-sentence maximum -- Consider what specific keywords or parameters you'll use for any function calls -Example interaction pattern: +Example interaction: ``` -User: "I need to automate my social media posting" -Otto: Let me find social media automation agents for you. find_agent(query="social media posting automation") I'll show you the best options once I get the results. +User: "Run the AI news agent for me" +Otto: run_agent(username_agent_slug="autogpt/ai-news") +[Tool returns: Agent accepts inputs - Required: topic. Optional: num_articles (default: 5)] +Otto: The AI News agent needs a topic. What topic would you like news about, or should I use the defaults? +User: "Use defaults" +Otto: run_agent(username_agent_slug="autogpt/ai-news", use_defaults=true) ``` -Respond conversationally and begin helping them find the right AutoGPT agent for their needs. - -KEEP ANSWERS TO 3 SENTENCES \ No newline at end of file +KEEP ANSWERS TO 3 SENTENCES diff --git a/autogpt_platform/backend/backend/server/v2/chat/tools/__init__.py b/autogpt_platform/backend/backend/server/v2/chat/tools/__init__.py index e0218bb7a2..50f0d9892b 100644 --- a/autogpt_platform/backend/backend/server/v2/chat/tools/__init__.py +++ b/autogpt_platform/backend/backend/server/v2/chat/tools/__init__.py @@ -6,27 +6,18 @@ from backend.server.v2.chat.model import ChatSession from .base import BaseTool from .find_agent import FindAgentTool -from .get_agent_details import GetAgentDetailsTool -from .get_required_setup_info import GetRequiredSetupInfoTool from .run_agent import RunAgentTool -from .setup_agent import SetupAgentTool if TYPE_CHECKING: from backend.server.v2.chat.response_model import StreamToolExecutionResult # Initialize tool instances find_agent_tool = FindAgentTool() -get_agent_details_tool = GetAgentDetailsTool() -get_required_setup_info_tool = GetRequiredSetupInfoTool() -setup_agent_tool = SetupAgentTool() run_agent_tool = RunAgentTool() # Export tools as OpenAI format tools: list[ChatCompletionToolParam] = [ find_agent_tool.as_openai_tool(), - get_agent_details_tool.as_openai_tool(), - get_required_setup_info_tool.as_openai_tool(), - setup_agent_tool.as_openai_tool(), run_agent_tool.as_openai_tool(), ] @@ -41,9 +32,6 @@ async def execute_tool( tool_map: dict[str, BaseTool] = { "find_agent": find_agent_tool, - "get_agent_details": get_agent_details_tool, - "get_required_setup_info": get_required_setup_info_tool, - "schedule_agent": setup_agent_tool, "run_agent": run_agent_tool, } if tool_name not in tool_map: diff --git a/autogpt_platform/backend/backend/server/v2/chat/tools/get_agent_details.py b/autogpt_platform/backend/backend/server/v2/chat/tools/get_agent_details.py deleted file mode 100644 index 8e8d42dbc9..0000000000 --- a/autogpt_platform/backend/backend/server/v2/chat/tools/get_agent_details.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Tool for getting detailed information about a specific agent.""" - -import logging -from typing import Any - -from backend.data import graph as graph_db -from backend.data.model import CredentialsMetaInput -from backend.server.v2.chat.model import ChatSession -from backend.server.v2.chat.tools.base import BaseTool -from backend.server.v2.chat.tools.models import ( - AgentDetails, - AgentDetailsResponse, - ErrorResponse, - ExecutionOptions, - ToolResponseBase, -) -from backend.server.v2.store import db as store_db -from backend.util.exceptions import DatabaseError, NotFoundError - -logger = logging.getLogger(__name__) - - -class GetAgentDetailsTool(BaseTool): - """Tool for getting detailed information about an agent.""" - - @property - def name(self) -> str: - return "get_agent_details" - - @property - def description(self) -> str: - return "Get detailed information about a specific agent including inputs, credentials required, and execution options." - - @property - def parameters(self) -> dict[str, Any]: - return { - "type": "object", - "properties": { - "username_agent_slug": { - "type": "string", - "description": "The marketplace agent slug (e.g., 'username/agent-name')", - }, - }, - "required": ["username_agent_slug"], - } - - async def _execute( - self, - user_id: str | None, - session: ChatSession, - **kwargs, - ) -> ToolResponseBase: - """Get detailed information about an agent. - - Args: - user_id: User ID (may be anonymous) - session_id: Chat session ID - username_agent_slug: Agent ID or slug - - Returns: - Pydantic response model - - """ - agent_id = kwargs.get("username_agent_slug", "").strip() - session_id = session.session_id - if not agent_id or "/" not in agent_id: - return ErrorResponse( - message="Please provide an agent ID in format 'creator/agent-name'", - session_id=session_id, - ) - - try: - # Always try to get from marketplace first - graph = None - store_agent = None - - # Check if it's a slug format (username/agent_name) - try: - # Parse username/agent_name from slug - username, agent_name = agent_id.split("/", 1) - store_agent = await store_db.get_store_agent_details( - username, agent_name - ) - logger.info(f"Found agent {agent_id} in marketplace") - except NotFoundError as e: - logger.debug(f"Failed to get from marketplace: {e}") - return ErrorResponse( - message=f"Agent '{agent_id}' not found", - session_id=session_id, - ) - except DatabaseError as e: - logger.error(f"Failed to get from marketplace: {e}") - return ErrorResponse( - message=f"Failed to get agent details: {e!s}", - session_id=session_id, - ) - - # If we found a store agent, get its graph - if store_agent: - try: - # Use get_available_graph to get the graph from store listing version - graph_meta = await store_db.get_available_graph( - store_agent.store_listing_version_id - ) - # Now get the full graph with that ID - graph = await graph_db.get_graph( - graph_id=graph_meta.id, - version=graph_meta.version, - user_id=None, # Public access - include_subgraphs=True, - ) - - except NotFoundError as e: - logger.error(f"Failed to get graph for store agent: {e}") - return ErrorResponse( - message=f"Failed to get graph for store agent: {e!s}", - session_id=session_id, - ) - except DatabaseError as e: - logger.error(f"Failed to get graph for store agent: {e}") - return ErrorResponse( - message=f"Failed to get graph for store agent: {e!s}", - session_id=session_id, - ) - - if not graph: - return ErrorResponse( - message=f"Agent '{agent_id}' not found", - session_id=session_id, - ) - - credentials_input_schema = graph.credentials_input_schema - - # Extract credentials from the JSON schema properties - credentials = [] - if ( - isinstance(credentials_input_schema, dict) - and "properties" in credentials_input_schema - ): - for cred_name, cred_schema in credentials_input_schema[ - "properties" - ].items(): - # Extract credential metadata from the schema - # The schema properties contain provider info and other metadata - - # Get provider from credentials_provider array or properties.provider.const - provider = "unknown" - if ( - "credentials_provider" in cred_schema - and cred_schema["credentials_provider"] - ): - provider = cred_schema["credentials_provider"][0] - elif ( - "properties" in cred_schema - and "provider" in cred_schema["properties"] - ): - provider = cred_schema["properties"]["provider"].get( - "const", "unknown" - ) - - # Get type from credentials_types array or properties.type.const - cred_type = "api_key" # Default - if ( - "credentials_types" in cred_schema - and cred_schema["credentials_types"] - ): - cred_type = cred_schema["credentials_types"][0] - elif ( - "properties" in cred_schema - and "type" in cred_schema["properties"] - ): - cred_type = cred_schema["properties"]["type"].get( - "const", "api_key" - ) - - credentials.append( - CredentialsMetaInput( - id=cred_name, - title=cred_schema.get("title", cred_name), - provider=provider, # type: ignore - type=cred_type, - ) - ) - - trigger_info = ( - graph.trigger_setup_info.model_dump() - if graph.trigger_setup_info - else None - ) - - agent_details = AgentDetails( - id=graph.id, - name=graph.name, - description=graph.description, - inputs=graph.input_schema, - credentials=credentials, - execution_options=ExecutionOptions( - # Currently a graph with a webhook can only be triggered by a webhook - manual=trigger_info is None, - scheduled=trigger_info is None, - webhook=trigger_info is not None, - ), - trigger_info=trigger_info, - ) - - return AgentDetailsResponse( - message=f"Found agent '{agent_details.name}'. When presenting the agent you do not need to mention the required credentials. You do not need to run this tool again for this agent.", - session_id=session_id, - agent=agent_details, - user_authenticated=user_id is not None, - graph_id=graph.id, - graph_version=graph.version, - ) - - except Exception as e: - logger.error(f"Error getting agent details: {e}", exc_info=True) - return ErrorResponse( - message=f"Failed to get agent details: {e!s}", - error=str(e), - session_id=session_id, - ) diff --git a/autogpt_platform/backend/backend/server/v2/chat/tools/get_agent_details_test.py b/autogpt_platform/backend/backend/server/v2/chat/tools/get_agent_details_test.py deleted file mode 100644 index ed93a47423..0000000000 --- a/autogpt_platform/backend/backend/server/v2/chat/tools/get_agent_details_test.py +++ /dev/null @@ -1,335 +0,0 @@ -import uuid - -import orjson -import pytest - -from backend.server.v2.chat.tools._test_data import ( - make_session, - setup_llm_test_data, - setup_test_data, -) -from backend.server.v2.chat.tools.get_agent_details import GetAgentDetailsTool - -# This is so the formatter doesn't remove the fixture imports -setup_llm_test_data = setup_llm_test_data -setup_test_data = setup_test_data - - -@pytest.mark.asyncio(scope="session") -async def test_get_agent_details_success(setup_test_data): - """Test successfully getting agent details from marketplace""" - # Use test data from fixture - user = setup_test_data["user"] - graph = setup_test_data["graph"] - store_submission = setup_test_data["store_submission"] - - # Create the tool instance - tool = GetAgentDetailsTool() - - # Build the proper marketplace agent_id format: username/slug - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - # Build session - session = make_session() - - # Execute the tool - response = await tool.execute( - user_id=user.id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - ) - - # Verify the response - assert response is not None - assert hasattr(response, "result") - - # Parse the result JSON - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - - # Check the basic structure - assert "agent" in result_data - assert "message" in result_data - assert "graph_id" in result_data - assert "graph_version" in result_data - assert "user_authenticated" in result_data - - # Check agent details - agent = result_data["agent"] - assert agent["id"] == graph.id - assert agent["name"] == "Test Agent" - assert ( - agent["description"] == "A simple test agent" - ) # Description from store submission - assert "inputs" in agent - assert "credentials" in agent - assert "execution_options" in agent - - # Check execution options - exec_options = agent["execution_options"] - assert "manual" in exec_options - assert "scheduled" in exec_options - assert "webhook" in exec_options - - # Check inputs schema - assert isinstance(agent["inputs"], dict) - # Should have properties for the input fields - if "properties" in agent["inputs"]: - assert "test_input" in agent["inputs"]["properties"] - - -@pytest.mark.asyncio(scope="session") -async def test_get_agent_details_with_llm_credentials(setup_llm_test_data): - """Test getting agent details for an agent that requires LLM credentials""" - # Use test data from fixture - user = setup_llm_test_data["user"] - store_submission = setup_llm_test_data["store_submission"] - - # Create the tool instance - tool = GetAgentDetailsTool() - - # Build the proper marketplace agent_id format - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - session = make_session(user_id=user.id) - - # Execute the tool - response = await tool.execute( - user_id=user.id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - ) - - # Verify the response - assert response is not None - assert hasattr(response, "result") - - # Parse the result JSON - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - - # Check that agent details are returned - assert "agent" in result_data - agent = result_data["agent"] - - # Check that credentials are listed - assert "credentials" in agent - credentials = agent["credentials"] - - # The LLM agent should have OpenAI credentials listed - assert isinstance(credentials, list) - - # Check that inputs include the user_prompt - assert "inputs" in agent - if "properties" in agent["inputs"]: - assert "user_prompt" in agent["inputs"]["properties"] - - -@pytest.mark.asyncio(scope="session") -async def test_get_agent_details_invalid_format(): - """Test error handling when agent_id is not in correct format""" - tool = GetAgentDetailsTool() - - session = make_session() - session.user_id = str(uuid.uuid4()) - - # Execute with invalid format (no slash) - response = await tool.execute( - user_id=session.user_id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug="invalid-format", - ) - - # Verify error response - assert response is not None - assert hasattr(response, "result") - - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - assert "message" in result_data - assert "creator/agent-name" in result_data["message"] - - -@pytest.mark.asyncio(scope="session") -async def test_get_agent_details_empty_slug(): - """Test error handling when agent_id is empty""" - tool = GetAgentDetailsTool() - - session = make_session() - session.user_id = str(uuid.uuid4()) - - # Execute with empty slug - response = await tool.execute( - user_id=session.user_id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug="", - ) - - # Verify error response - assert response is not None - assert hasattr(response, "result") - - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - assert "message" in result_data - assert "creator/agent-name" in result_data["message"] - - -@pytest.mark.asyncio(scope="session") -async def test_get_agent_details_not_found(): - """Test error handling when agent is not found in marketplace""" - tool = GetAgentDetailsTool() - - session = make_session() - session.user_id = str(uuid.uuid4()) - - # Execute with non-existent agent - response = await tool.execute( - user_id=session.user_id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug="nonexistent/agent", - ) - - # Verify error response - assert response is not None - assert hasattr(response, "result") - - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - assert "message" in result_data - assert "not found" in result_data["message"].lower() - - -@pytest.mark.asyncio(scope="session") -async def test_get_agent_details_anonymous_user(setup_test_data): - """Test getting agent details as an anonymous user (no user_id)""" - # Use test data from fixture - user = setup_test_data["user"] - store_submission = setup_test_data["store_submission"] - - # Create the tool instance - tool = GetAgentDetailsTool() - - # Build the proper marketplace agent_id format - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - session = make_session() - # session.user_id stays as None - - # Execute the tool without a user_id (anonymous) - response = await tool.execute( - user_id=None, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - ) - - # Verify the response - assert response is not None - assert hasattr(response, "result") - - # Parse the result JSON - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - - # Should still get agent details - assert "agent" in result_data - assert "user_authenticated" in result_data - - # User should be marked as not authenticated - assert result_data["user_authenticated"] is False - - -@pytest.mark.asyncio(scope="session") -async def test_get_agent_details_authenticated_user(setup_test_data): - """Test getting agent details as an authenticated user""" - # Use test data from fixture - user = setup_test_data["user"] - store_submission = setup_test_data["store_submission"] - - # Create the tool instance - tool = GetAgentDetailsTool() - - # Build the proper marketplace agent_id format - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - session = make_session() - session.user_id = user.id - - # Execute the tool with a user_id (authenticated) - response = await tool.execute( - user_id=user.id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - ) - - # Verify the response - assert response is not None - assert hasattr(response, "result") - - # Parse the result JSON - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - - # Should get agent details - assert "agent" in result_data - assert "user_authenticated" in result_data - - # User should be marked as authenticated - assert result_data["user_authenticated"] is True - - -@pytest.mark.asyncio(scope="session") -async def test_get_agent_details_includes_execution_options(setup_test_data): - """Test that agent details include execution options""" - # Use test data from fixture - user = setup_test_data["user"] - store_submission = setup_test_data["store_submission"] - - # Create the tool instance - tool = GetAgentDetailsTool() - - # Build the proper marketplace agent_id format - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - session = make_session() - session.user_id = user.id - - # Execute the tool - response = await tool.execute( - user_id=user.id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - ) - - # Verify the response - assert response is not None - assert hasattr(response, "result") - - # Parse the result JSON - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - - # Check execution options - assert "agent" in result_data - agent = result_data["agent"] - assert "execution_options" in agent - - exec_options = agent["execution_options"] - - # These should all be boolean values - assert isinstance(exec_options["manual"], bool) - assert isinstance(exec_options["scheduled"], bool) - assert isinstance(exec_options["webhook"], bool) - - # For a regular agent (no webhook), manual and scheduled should be True - assert exec_options["manual"] is True - assert exec_options["scheduled"] is True - assert exec_options["webhook"] is False diff --git a/autogpt_platform/backend/backend/server/v2/chat/tools/get_required_setup_info.py b/autogpt_platform/backend/backend/server/v2/chat/tools/get_required_setup_info.py deleted file mode 100644 index 3ac20282bb..0000000000 --- a/autogpt_platform/backend/backend/server/v2/chat/tools/get_required_setup_info.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Tool for getting required setup information for an agent.""" - -import logging -from typing import Any - -from backend.integrations.creds_manager import IntegrationCredentialsManager -from backend.server.v2.chat.model import ChatSession -from backend.server.v2.chat.tools.base import BaseTool -from backend.server.v2.chat.tools.get_agent_details import GetAgentDetailsTool -from backend.server.v2.chat.tools.models import ( - AgentDetailsResponse, - ErrorResponse, - SetupInfo, - SetupRequirementsResponse, - ToolResponseBase, - UserReadiness, -) - -logger = logging.getLogger(__name__) - - -class GetRequiredSetupInfoTool(BaseTool): - """Tool for getting required setup information including credentials and inputs.""" - - @property - def name(self) -> str: - return "get_required_setup_info" - - @property - def description(self) -> str: - return """Check if an agent can be set up with the provided input data and credentials. - Call this AFTER get_agent_details to validate that you have all required inputs. - Pass the input dictionary you plan to use with run_agent or setup_agent to verify it's complete.""" - - @property - def parameters(self) -> dict[str, Any]: - return { - "type": "object", - "properties": { - "username_agent_slug": { - "type": "string", - "description": "The marketplace agent slug (e.g., 'username/agent-name' or just 'agent-name' to search)", - }, - "inputs": { - "type": "object", - "description": "The input dictionary you plan to provide. Should contain ALL required inputs from get_agent_details", - "additionalProperties": True, - }, - }, - "required": ["username_agent_slug"], - } - - @property - def requires_auth(self) -> bool: - """This tool requires authentication.""" - return True - - async def _execute( - self, - user_id: str | None, - session: ChatSession, - **kwargs, - ) -> ToolResponseBase: - """ - Retrieve and validate the required setup information for running or configuring an agent. - - This checks all required credentials and input fields based on the agent details, - and verifies user readiness to run the agent based on provided inputs and available credentials. - - Args: - user_id: The authenticated user's ID (must not be None; authentication required). - session_id: The chat session ID. - agent_id: The agent's marketplace slug (e.g. 'username/agent-name'). Also accepts Graph ID. - agent_version: (Optional) Specific agent/graph version (if applicable). - - Returns: - SetupRequirementsResponse containing: - - agent and graph info, - - credential and input requirements, - - user readiness and missing credentials/fields, - - setup instructions. - """ - assert ( - user_id is not None - ), "GetRequiredSetupInfoTool - This should never happen user_id is None when auth is required" - session_id = session.session_id - # Call _execute directly since we're calling internally from another tool - agent_details = await GetAgentDetailsTool()._execute(user_id, session, **kwargs) - - if isinstance(agent_details, ErrorResponse): - return agent_details - - if not isinstance(agent_details, AgentDetailsResponse): - return ErrorResponse( - message="Failed to get agent details", - session_id=session_id, - ) - - available_creds = await IntegrationCredentialsManager().store.get_all_creds( - user_id - ) - required_credentials = [] - - # Check if user has credentials matching the required provider/type - for c in agent_details.agent.credentials: - # Check if any available credential matches this provider and type - has_matching_cred = any( - cred.provider == c.provider and cred.type == c.type - for cred in available_creds - ) - if not has_matching_cred: - required_credentials.append(c) - - required_fields = set(agent_details.agent.inputs.get("required", [])) - provided_inputs = kwargs.get("inputs", {}) - missing_inputs = required_fields - set(provided_inputs.keys()) - - missing_credentials = {c.id: c.model_dump() for c in required_credentials} - - user_readiness = UserReadiness( - has_all_credentials=len(required_credentials) == 0, - missing_credentials=missing_credentials, - ready_to_run=len(missing_inputs) == 0 and len(required_credentials) == 0, - ) - # Convert execution options to list of available modes - exec_opts = agent_details.agent.execution_options - execution_modes = [] - if exec_opts.manual: - execution_modes.append("manual") - if exec_opts.scheduled: - execution_modes.append("scheduled") - if exec_opts.webhook: - execution_modes.append("webhook") - - # Convert input schema to list of input field info - inputs_list = [] - if ( - isinstance(agent_details.agent.inputs, dict) - and "properties" in agent_details.agent.inputs - ): - for field_name, field_schema in agent_details.agent.inputs[ - "properties" - ].items(): - inputs_list.append( - { - "name": field_name, - "title": field_schema.get("title", field_name), - "type": field_schema.get("type", "string"), - "description": field_schema.get("description", ""), - "required": field_name - in agent_details.agent.inputs.get("required", []), - } - ) - - requirements = { - "credentials": agent_details.agent.credentials, - "inputs": inputs_list, - "execution_modes": execution_modes, - } - message = "" - if len(agent_details.agent.credentials) > 0: - message = "The user needs to enter credentials before proceeding. Please wait until you have a message informing you that the credentials have been entered." - elif len(inputs_list) > 0: - message = ( - "The user needs to enter inputs before proceeding. Please wait until you have a message informing you that the inputs have been entered. The inputs are: " - + ", ".join([input["name"] for input in inputs_list]) - ) - else: - message = "The agent is ready to run. Please call the run_agent tool with the agent ID." - - return SetupRequirementsResponse( - message=message, - session_id=session_id, - setup_info=SetupInfo( - agent_id=agent_details.agent.id, - agent_name=agent_details.agent.name, - user_readiness=user_readiness, - requirements=requirements, - ), - graph_id=agent_details.graph_id, - graph_version=agent_details.graph_version, - ) diff --git a/autogpt_platform/backend/backend/server/v2/chat/tools/get_required_setup_info_test.py b/autogpt_platform/backend/backend/server/v2/chat/tools/get_required_setup_info_test.py deleted file mode 100644 index ceb3ad7ebd..0000000000 --- a/autogpt_platform/backend/backend/server/v2/chat/tools/get_required_setup_info_test.py +++ /dev/null @@ -1,331 +0,0 @@ -import uuid - -import orjson -import pytest - -from backend.server.v2.chat.tools._test_data import ( - make_session, - setup_firecrawl_test_data, - setup_llm_test_data, - setup_test_data, -) -from backend.server.v2.chat.tools.get_required_setup_info import ( - GetRequiredSetupInfoTool, -) - -# This is so the formatter doesn't remove the fixture imports -setup_llm_test_data = setup_llm_test_data -setup_test_data = setup_test_data -setup_firecrawl_test_data = setup_firecrawl_test_data - - -@pytest.mark.asyncio(scope="session") -async def test_get_required_setup_info_success(setup_test_data): - """Test successfully getting setup info for a simple agent""" - user = setup_test_data["user"] - graph = setup_test_data["graph"] - store_submission = setup_test_data["store_submission"] - - tool = GetRequiredSetupInfoTool() - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - session = make_session(user_id=user.id) - response = await tool.execute( - user_id=user.id, - session_id=str(uuid.uuid4()), - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - inputs={"test_input": "Hello World"}, - session=session, - ) - - assert response is not None - assert hasattr(response, "result") - - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - - assert "setup_info" in result_data - setup_info = result_data["setup_info"] - - assert "agent_id" in setup_info - assert setup_info["agent_id"] == graph.id - assert "agent_name" in setup_info - assert setup_info["agent_name"] == "Test Agent" - - assert "requirements" in setup_info - requirements = setup_info["requirements"] - assert "credentials" in requirements - assert "inputs" in requirements - assert "execution_modes" in requirements - - assert isinstance(requirements["credentials"], list) - assert len(requirements["credentials"]) == 0 - - assert isinstance(requirements["inputs"], list) - if len(requirements["inputs"]) > 0: - first_input = requirements["inputs"][0] - assert "name" in first_input - assert "title" in first_input - assert "type" in first_input - - assert isinstance(requirements["execution_modes"], list) - assert "manual" in requirements["execution_modes"] - assert "scheduled" in requirements["execution_modes"] - - assert "user_readiness" in setup_info - user_readiness = setup_info["user_readiness"] - assert "has_all_credentials" in user_readiness - assert "ready_to_run" in user_readiness - assert user_readiness["ready_to_run"] is True - - -@pytest.mark.asyncio(scope="session") -async def test_get_required_setup_info_missing_credentials(setup_firecrawl_test_data): - """Test getting setup info for an agent requiring missing credentials""" - user = setup_firecrawl_test_data["user"] - store_submission = setup_firecrawl_test_data["store_submission"] - - tool = GetRequiredSetupInfoTool() - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - session = make_session(user_id=user.id) - response = await tool.execute( - user_id=user.id, - session_id=str(uuid.uuid4()), - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - inputs={"url": "https://example.com"}, - session=session, - ) - - assert response is not None - assert hasattr(response, "result") - - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - - assert "setup_info" in result_data - setup_info = result_data["setup_info"] - - requirements = setup_info["requirements"] - assert "credentials" in requirements - assert isinstance(requirements["credentials"], list) - assert len(requirements["credentials"]) > 0 - - firecrawl_cred = requirements["credentials"][0] - assert "provider" in firecrawl_cred - assert firecrawl_cred["provider"] == "firecrawl" - assert "type" in firecrawl_cred - assert firecrawl_cred["type"] == "api_key" - - user_readiness = setup_info["user_readiness"] - assert user_readiness["has_all_credentials"] is False - assert user_readiness["ready_to_run"] is False - - assert "missing_credentials" in user_readiness - assert isinstance(user_readiness["missing_credentials"], dict) - assert len(user_readiness["missing_credentials"]) > 0 - - -@pytest.mark.asyncio(scope="session") -async def test_get_required_setup_info_with_available_credentials(setup_llm_test_data): - """Test getting setup info when user has required credentials""" - user = setup_llm_test_data["user"] - store_submission = setup_llm_test_data["store_submission"] - - tool = GetRequiredSetupInfoTool() - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - session = make_session(user_id=user.id) - response = await tool.execute( - user_id=user.id, - session_id=str(uuid.uuid4()), - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - inputs={"user_prompt": "What is 2+2?"}, - session=session, - ) - - assert response is not None - assert hasattr(response, "result") - - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - - setup_info = result_data["setup_info"] - - user_readiness = setup_info["user_readiness"] - assert user_readiness["has_all_credentials"] is True - assert user_readiness["ready_to_run"] is True - - assert "missing_credentials" in user_readiness - assert len(user_readiness["missing_credentials"]) == 0 - - -@pytest.mark.asyncio(scope="session") -async def test_get_required_setup_info_missing_inputs(setup_test_data): - """Test getting setup info when required inputs are not provided""" - user = setup_test_data["user"] - store_submission = setup_test_data["store_submission"] - - tool = GetRequiredSetupInfoTool() - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - session = make_session(user_id=user.id) - response = await tool.execute( - user_id=user.id, - session_id=str(uuid.uuid4()), - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - inputs={}, # Empty inputs - session=session, - ) - - assert response is not None - assert hasattr(response, "result") - - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - - setup_info = result_data["setup_info"] - - requirements = setup_info["requirements"] - assert "inputs" in requirements - assert isinstance(requirements["inputs"], list) - - user_readiness = setup_info["user_readiness"] - assert "ready_to_run" in user_readiness - - -@pytest.mark.asyncio(scope="session") -async def test_get_required_setup_info_invalid_agent(): - """Test getting setup info for a non-existent agent""" - tool = GetRequiredSetupInfoTool() - - session = make_session(user_id=None) - response = await tool.execute( - user_id=str(uuid.uuid4()), - session_id=str(uuid.uuid4()), - tool_call_id=str(uuid.uuid4()), - username_agent_slug="invalid/agent", - inputs={}, - session=session, - ) - - assert response is not None - assert hasattr(response, "result") - - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - assert "message" in result_data - assert any( - phrase in result_data["message"].lower() - for phrase in ["not found", "failed", "error"] - ) - - -@pytest.mark.asyncio(scope="session") -async def test_get_required_setup_info_graph_metadata(setup_test_data): - """Test that setup info includes graph metadata""" - user = setup_test_data["user"] - graph = setup_test_data["graph"] - store_submission = setup_test_data["store_submission"] - - tool = GetRequiredSetupInfoTool() - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - session = make_session(user_id=user.id) - response = await tool.execute( - user_id=user.id, - session_id=str(uuid.uuid4()), - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - inputs={"test_input": "test"}, - session=session, - ) - - assert response is not None - assert hasattr(response, "result") - - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - - assert "graph_id" in result_data - assert result_data["graph_id"] == graph.id - assert "graph_version" in result_data - assert result_data["graph_version"] == graph.version - - -@pytest.mark.asyncio(scope="session") -async def test_get_required_setup_info_inputs_structure(setup_test_data): - """Test that inputs are properly structured as a list""" - user = setup_test_data["user"] - store_submission = setup_test_data["store_submission"] - - tool = GetRequiredSetupInfoTool() - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - session = make_session(user_id=user.id) - response = await tool.execute( - user_id=user.id, - session_id=str(uuid.uuid4()), - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - inputs={}, - session=session, - ) - - assert response is not None - assert hasattr(response, "result") - - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - - setup_info = result_data["setup_info"] - requirements = setup_info["requirements"] - - assert isinstance(requirements["inputs"], list) - - for input_field in requirements["inputs"]: - assert isinstance(input_field, dict) - assert "name" in input_field - assert "title" in input_field - assert "type" in input_field - assert "description" in input_field - assert "required" in input_field - assert isinstance(input_field["required"], bool) - - -@pytest.mark.asyncio(scope="session") -async def test_get_required_setup_info_execution_modes_structure(setup_test_data): - """Test that execution_modes are properly structured as a list""" - user = setup_test_data["user"] - store_submission = setup_test_data["store_submission"] - - tool = GetRequiredSetupInfoTool() - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - session = make_session(user_id=user.id) - response = await tool.execute( - user_id=user.id, - session_id=str(uuid.uuid4()), - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - inputs={}, - session=session, - ) - - assert response is not None - assert hasattr(response, "result") - - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - - setup_info = result_data["setup_info"] - requirements = setup_info["requirements"] - - assert isinstance(requirements["execution_modes"], list) - for mode in requirements["execution_modes"]: - assert isinstance(mode, str) - assert mode in ["manual", "scheduled", "webhook"] diff --git a/autogpt_platform/backend/backend/server/v2/chat/tools/models.py b/autogpt_platform/backend/backend/server/v2/chat/tools/models.py index d18d2c2d23..a3fbbe025c 100644 --- a/autogpt_platform/backend/backend/server/v2/chat/tools/models.py +++ b/autogpt_platform/backend/backend/server/v2/chat/tools/models.py @@ -13,17 +13,9 @@ class ResponseType(str, Enum): AGENT_CAROUSEL = "agent_carousel" AGENT_DETAILS = "agent_details" - AGENT_DETAILS_NEED_LOGIN = "agent_details_need_login" - AGENT_DETAILS_NEED_CREDENTIALS = "agent_details_need_credentials" SETUP_REQUIREMENTS = "setup_requirements" - SCHEDULE_CREATED = "schedule_created" - WEBHOOK_CREATED = "webhook_created" - PRESET_CREATED = "preset_created" EXECUTION_STARTED = "execution_started" NEED_LOGIN = "need_login" - NEED_CREDENTIALS = "need_credentials" - INSUFFICIENT_CREDITS = "insufficient_credits" - VALIDATION_ERROR = "validation_error" ERROR = "error" NO_RESULTS = "no_results" SUCCESS = "success" @@ -112,7 +104,7 @@ class AgentDetails(BaseModel): class AgentDetailsResponse(ToolResponseBase): - """Response for get_agent_details tool.""" + """Response for get_details action.""" type: ResponseType = ResponseType.AGENT_DETAILS agent: AgentDetails @@ -121,51 +113,7 @@ class AgentDetailsResponse(ToolResponseBase): graph_version: int | None = None -class AgentDetailsNeedLoginResponse(ToolResponseBase): - """Response when agent details need login.""" - - type: ResponseType = ResponseType.AGENT_DETAILS_NEED_LOGIN - agent: AgentDetails - agent_info: dict[str, Any] | None = None - graph_id: str | None = None - graph_version: int | None = None - - -class AgentDetailsNeedCredentialsResponse(ToolResponseBase): - """Response when agent needs credentials to be configured.""" - - type: ResponseType = ResponseType.NEED_CREDENTIALS - agent: AgentDetails - credentials_schema: dict[str, Any] - agent_info: dict[str, Any] | None = None - graph_id: str | None = None - graph_version: int | None = None - - # Setup info models -class SetupRequirementInfo(BaseModel): - """Setup requirement information.""" - - key: str - provider: str - required: bool = True - user_has: bool = False - credential_id: str | None = None - type: str | None = None - scopes: list[str] | None = None - description: str | None = None - - -class ExecutionModeInfo(BaseModel): - """Execution mode information.""" - - type: str # manual, scheduled, webhook - description: str - supported: bool - config_required: dict[str, str] | None = None - trigger_info: dict[str, Any] | None = None - - class UserReadiness(BaseModel): """User readiness status.""" @@ -187,11 +135,10 @@ class SetupInfo(BaseModel): }, ) user_readiness: UserReadiness = Field(default_factory=UserReadiness) - setup_instructions: list[str] = [] class SetupRequirementsResponse(ToolResponseBase): - """Response for get_required_setup_info tool.""" + """Response for validate action.""" type: ResponseType = ResponseType.SETUP_REQUIREMENTS setup_info: SetupInfo @@ -199,70 +146,17 @@ class SetupRequirementsResponse(ToolResponseBase): graph_version: int | None = None -# Setup agent models -class ScheduleCreatedResponse(ToolResponseBase): - """Response for scheduled agent setup.""" - - type: ResponseType = ResponseType.SCHEDULE_CREATED - schedule_id: str - name: str - cron: str - timezone: str = "UTC" - next_run: str | None = None - graph_id: str - graph_name: str - - -class WebhookCreatedResponse(ToolResponseBase): - """Response for webhook agent setup.""" - - type: ResponseType = ResponseType.WEBHOOK_CREATED - webhook_id: str - webhook_url: str - preset_id: str | None = None - name: str - graph_id: str - graph_name: str - - -class PresetCreatedResponse(ToolResponseBase): - """Response for preset agent setup.""" - - type: ResponseType = ResponseType.PRESET_CREATED - preset_id: str - name: str - graph_id: str - graph_name: str - - -# Run agent models +# Execution models class ExecutionStartedResponse(ToolResponseBase): - """Response for agent execution started.""" + """Response for run/schedule actions.""" type: ResponseType = ResponseType.EXECUTION_STARTED execution_id: str graph_id: str graph_name: str + library_agent_id: str | None = None + library_agent_link: str | None = None status: str = "QUEUED" - ended_at: str | None = None - outputs: dict[str, Any] | None = None - error: str | None = None - timeout_reached: bool | None = None - - -class InsufficientCreditsResponse(ToolResponseBase): - """Response for insufficient credits.""" - - type: ResponseType = ResponseType.INSUFFICIENT_CREDITS - balance: float - - -class ValidationErrorResponse(ToolResponseBase): - """Response for validation errors.""" - - type: ResponseType = ResponseType.VALIDATION_ERROR - error: str - details: dict[str, Any] | None = None # Auth/error models diff --git a/autogpt_platform/backend/backend/server/v2/chat/tools/run_agent.py b/autogpt_platform/backend/backend/server/v2/chat/tools/run_agent.py index 4b3754b322..f8e407cf61 100644 --- a/autogpt_platform/backend/backend/server/v2/chat/tools/run_agent.py +++ b/autogpt_platform/backend/backend/server/v2/chat/tools/run_agent.py @@ -1,34 +1,66 @@ -"""Tool for running an agent manually (one-off execution).""" +"""Unified tool for agent operations with automatic state detection.""" import logging from typing import Any -from backend.data.graph import get_graph +from backend.data.graph import GraphModel from backend.data.model import CredentialsMetaInput +from backend.data.user import get_user_by_id from backend.executor import utils as execution_utils -from backend.integrations.creds_manager import IntegrationCredentialsManager from backend.server.v2.chat.config import ChatConfig from backend.server.v2.chat.model import ChatSession from backend.server.v2.chat.tools.base import BaseTool -from backend.server.v2.chat.tools.get_required_setup_info import ( - GetRequiredSetupInfoTool, -) from backend.server.v2.chat.tools.models import ( + AgentDetails, + AgentDetailsResponse, ErrorResponse, + ExecutionOptions, ExecutionStartedResponse, SetupInfo, SetupRequirementsResponse, ToolResponseBase, + UserReadiness, +) +from backend.server.v2.chat.tools.utils import ( + check_user_has_required_credentials, + extract_credentials_from_schema, + fetch_graph_from_store_slug, + get_or_create_library_agent, + match_user_credentials_to_graph, +) +from backend.util.clients import get_scheduler_client +from backend.util.exceptions import DatabaseError, NotFoundError +from backend.util.timezone_utils import ( + convert_utc_time_to_user_timezone, + get_user_timezone_or_utc, ) -from backend.server.v2.library import db as library_db -from backend.server.v2.library import model as library_model logger = logging.getLogger(__name__) config = ChatConfig() +# Constants for response messages +MSG_DO_NOT_RUN_AGAIN = "Do not run again unless explicitly requested." +MSG_DO_NOT_SCHEDULE_AGAIN = "Do not schedule again unless explicitly requested." +MSG_ASK_USER_FOR_VALUES = ( + "Ask the user what values to use, or call again with use_defaults=true " + "to run with default values." +) +MSG_WHAT_VALUES_TO_USE = ( + "What values would you like to use, or would you like to run with defaults?" +) + class RunAgentTool(BaseTool): - """Tool for executing an agent manually with immediate results.""" + """Unified tool for agent operations with automatic state detection. + + The tool automatically determines what to do based on provided parameters: + 1. Fetches agent details (always, silently) + 2. Checks if required inputs are provided + 3. Checks if user has required credentials + 4. Runs immediately OR schedules (if cron is provided) + + The response tells the caller what's missing or confirms execution. + """ @property def name(self) -> str: @@ -36,11 +68,15 @@ class RunAgentTool(BaseTool): @property def description(self) -> str: - return """Run an agent immediately (one-off manual execution). - IMPORTANT: Before calling this tool, you MUST first call get_agent_details to determine what inputs are required. - The 'inputs' parameter must be a dictionary containing ALL required input values identified by get_agent_details. - Example: If get_agent_details shows required inputs 'search_query' and 'max_results', you must pass: - inputs={"search_query": "user's query", "max_results": 10}""" + return """Run or schedule an agent from the marketplace. + + The tool automatically handles the setup flow: + - Returns missing inputs if required fields are not provided + - Returns missing credentials if user needs to configure them + - Executes immediately if all requirements are met + - Schedules execution if cron expression is provided + + For scheduled execution, provide: schedule_name, cron, and optionally timezone.""" @property def parameters(self) -> dict[str, Any]: @@ -49,20 +85,36 @@ class RunAgentTool(BaseTool): "properties": { "username_agent_slug": { "type": "string", - "description": "The ID of the agent to run (graph ID or marketplace slug)", + "description": "Agent identifier in format 'username/agent-name'", }, "inputs": { "type": "object", - "description": 'REQUIRED: Dictionary of input values. Must include ALL required inputs from get_agent_details. Format: {"input_name": value}', + "description": "Input values for the agent", "additionalProperties": True, }, + "use_defaults": { + "type": "boolean", + "description": "Set to true to run with default values (user must confirm)", + }, + "schedule_name": { + "type": "string", + "description": "Name for scheduled execution (triggers scheduling mode)", + }, + "cron": { + "type": "string", + "description": "Cron expression (5 fields: min hour day month weekday)", + }, + "timezone": { + "type": "string", + "description": "IANA timezone for schedule (default: UTC)", + }, }, "required": ["username_agent_slug"], } @property def requires_auth(self) -> bool: - """This tool requires authentication.""" + """All operations require authentication.""" return True async def _execute( @@ -71,186 +123,362 @@ class RunAgentTool(BaseTool): session: ChatSession, **kwargs, ) -> ToolResponseBase: - """Execute an agent manually. - - Args: - user_id: Authenticated user ID - session_id: Chat session ID - **kwargs: Execution parameters - - Returns: - JSON formatted execution result - - """ - - assert ( - user_id is not None - ), "User ID is required to run an agent. Superclass enforces authentication." - - session_id = session.session_id - username_agent_slug = kwargs.get("username_agent_slug", "").strip() + """Execute the tool with automatic state detection.""" + agent_slug = kwargs.get("username_agent_slug", "").strip() inputs = kwargs.get("inputs", {}) + use_defaults = kwargs.get("use_defaults", False) + schedule_name = kwargs.get("schedule_name", "").strip() + cron = kwargs.get("cron", "").strip() + timezone = kwargs.get("timezone", "UTC").strip() + session_id = session.session_id - # Call _execute directly since we're calling internally from another tool - response = await GetRequiredSetupInfoTool()._execute(user_id, session, **kwargs) - - if not isinstance(response, SetupRequirementsResponse): + # Validate agent slug format + if not agent_slug or "/" not in agent_slug: return ErrorResponse( - message="Failed to get required setup information", + message="Please provide an agent slug in format 'username/agent-name'", session_id=session_id, ) - setup_info = SetupInfo.model_validate(response.setup_info) - - if not setup_info.user_readiness.ready_to_run: + # Auth is required + if not user_id: return ErrorResponse( - message=f"User is not ready to run the agent. User Readiness: {setup_info.user_readiness.model_dump_json()} Requirments: {setup_info.requirements}", + message="Authentication required. Please sign in to use this tool.", session_id=session_id, ) - # Get the graph using the graph_id and graph_version from the setup response - if not response.graph_id or not response.graph_version: - return ErrorResponse( - message=f"Graph information not available for {username_agent_slug}", - session_id=session_id, - ) + # Determine if this is a schedule request + is_schedule = bool(schedule_name or cron) - graph = await get_graph( - graph_id=response.graph_id, - version=response.graph_version, - user_id=None, # Public access for store graphs - include_subgraphs=True, - ) + try: + # Step 1: Fetch agent details (always happens first) + username, agent_name = agent_slug.split("/", 1) + graph, store_agent = await fetch_graph_from_store_slug(username, agent_name) - if not graph: - return ErrorResponse( - message=f"Graph {username_agent_slug} ({response.graph_id}v{response.graph_version}) not found", - session_id=session_id, - ) - - if graph and ( - session.successful_agent_runs.get(graph.id, 0) >= config.max_agent_runs - ): - return ErrorResponse( - message="Maximum number of agent schedules reached. You can't schedule this agent again in this chat session.", - session_id=session.session_id, - ) - - # Check if we already have a library agent for this graph - existing_library_agent = await library_db.get_library_agent_by_graph_id( - graph_id=graph.id, user_id=user_id - ) - if not existing_library_agent: - # Now we need to add the graph to the users library - library_agents: list[library_model.LibraryAgent] = ( - await library_db.create_library_agent( - graph=graph, - user_id=user_id, - create_library_agents_for_sub_graphs=False, - ) - ) - assert len(library_agents) == 1, "Expected 1 library agent to be created" - library_agent = library_agents[0] - else: - library_agent = existing_library_agent - - # Build credentials mapping for the graph - graph_credentials_inputs: dict[str, CredentialsMetaInput] = {} - - # Get aggregated credentials requirements from the graph - aggregated_creds = graph.aggregate_credentials_inputs() - logger.debug( - f"Matching credentials for graph {graph.id}: {len(aggregated_creds)} required" - ) - - if aggregated_creds: - # Get all available credentials for the user - creds_manager = IntegrationCredentialsManager() - available_creds = await creds_manager.store.get_all_creds(user_id) - - # Track unmatched credentials for error reporting - missing_creds: list[str] = [] - - # For each required credential field, find a matching user credential - # field_info.provider is a frozenset because aggregate_credentials_inputs() - # combines requirements from multiple nodes. A credential matches if its - # provider is in the set of acceptable providers. - for credential_field_name, ( - credential_requirements, - _node_fields, - ) in aggregated_creds.items(): - # Find first matching credential by provider and type - matching_cred = next( - ( - cred - for cred in available_creds - if cred.provider in credential_requirements.provider - and cred.type in credential_requirements.supported_types - ), - None, - ) - - if matching_cred: - # Use Pydantic validation to ensure type safety - try: - graph_credentials_inputs[credential_field_name] = ( - CredentialsMetaInput( - id=matching_cred.id, - provider=matching_cred.provider, # type: ignore - type=matching_cred.type, - title=matching_cred.title, - ) - ) - except Exception as e: - logger.error( - f"Failed to create CredentialsMetaInput for field '{credential_field_name}': " - f"provider={matching_cred.provider}, type={matching_cred.type}, " - f"credential_id={matching_cred.id}", - exc_info=True, - ) - missing_creds.append( - f"{credential_field_name} (validation failed: {e})" - ) - else: - missing_creds.append( - f"{credential_field_name} " - f"(requires provider in {list(credential_requirements.provider)}, " - f"type in {list(credential_requirements.supported_types)})" - ) - - # Fail fast if any required credentials are missing - if missing_creds: - logger.warning( - f"Cannot execute agent - missing credentials: {missing_creds}" - ) + if not graph: return ErrorResponse( - message=f"Cannot execute agent: missing {len(missing_creds)} required credential(s). You need to call the get_required_setup_info tool to setup the credentials." - f"Please set up the following credentials: {', '.join(missing_creds)}", + message=f"Agent '{agent_slug}' not found in marketplace", session_id=session_id, - details={"missing_credentials": missing_creds}, ) - logger.info( - f"Credential matching complete: {len(graph_credentials_inputs)}/{len(aggregated_creds)} matched" + # Step 2: Check credentials + graph_credentials, missing_creds = await match_user_credentials_to_graph( + user_id, graph ) - # At this point we know the user is ready to run the agent - # So we can execute the agent + if missing_creds: + # Return credentials needed response with input data info + # The UI handles credential setup automatically, so the message + # focuses on asking about input data + credentials = extract_credentials_from_schema( + graph.credentials_input_schema + ) + missing_creds_check = await check_user_has_required_credentials( + user_id, credentials + ) + missing_credentials_dict = { + c.id: c.model_dump() for c in missing_creds_check + } + + return SetupRequirementsResponse( + message=self._build_inputs_message(graph, MSG_WHAT_VALUES_TO_USE), + session_id=session_id, + setup_info=SetupInfo( + agent_id=graph.id, + agent_name=graph.name, + user_readiness=UserReadiness( + has_all_credentials=False, + missing_credentials=missing_credentials_dict, + ready_to_run=False, + ), + requirements={ + "credentials": [c.model_dump() for c in credentials], + "inputs": self._get_inputs_list(graph.input_schema), + "execution_modes": self._get_execution_modes(graph), + }, + ), + graph_id=graph.id, + graph_version=graph.version, + ) + + # Step 3: Check inputs + # Get all available input fields from schema + input_properties = graph.input_schema.get("properties", {}) + required_fields = set(graph.input_schema.get("required", [])) + provided_inputs = set(inputs.keys()) + + # If agent has inputs but none were provided AND use_defaults is not set, + # always show what's available first so user can decide + if input_properties and not provided_inputs and not use_defaults: + credentials = extract_credentials_from_schema( + graph.credentials_input_schema + ) + return AgentDetailsResponse( + message=self._build_inputs_message(graph, MSG_ASK_USER_FOR_VALUES), + session_id=session_id, + agent=self._build_agent_details(graph, credentials), + user_authenticated=True, + graph_id=graph.id, + graph_version=graph.version, + ) + + # Check if required inputs are missing (and not using defaults) + missing_inputs = required_fields - provided_inputs + + if missing_inputs and not use_defaults: + # Return agent details with missing inputs info + credentials = extract_credentials_from_schema( + graph.credentials_input_schema + ) + return AgentDetailsResponse( + message=( + f"Agent '{graph.name}' is missing required inputs: " + f"{', '.join(missing_inputs)}. " + "Please provide these values to run the agent." + ), + session_id=session_id, + agent=self._build_agent_details(graph, credentials), + user_authenticated=True, + graph_id=graph.id, + graph_version=graph.version, + ) + + # Step 4: Execute or Schedule + if is_schedule: + return await self._schedule_agent( + user_id=user_id, + session=session, + graph=graph, + graph_credentials=graph_credentials, + inputs=inputs, + schedule_name=schedule_name, + cron=cron, + timezone=timezone, + ) + else: + return await self._run_agent( + user_id=user_id, + session=session, + graph=graph, + graph_credentials=graph_credentials, + inputs=inputs, + ) + + except NotFoundError as e: + return ErrorResponse( + message=f"Agent '{agent_slug}' not found", + error=str(e) if str(e) else "not_found", + session_id=session_id, + ) + except DatabaseError as e: + logger.error(f"Database error: {e}", exc_info=True) + return ErrorResponse( + message=f"Failed to process request: {e!s}", + error=str(e), + session_id=session_id, + ) + except Exception as e: + logger.error(f"Error processing agent request: {e}", exc_info=True) + return ErrorResponse( + message=f"Failed to process request: {e!s}", + error=str(e), + session_id=session_id, + ) + + def _get_inputs_list(self, input_schema: dict[str, Any]) -> list[dict[str, Any]]: + """Extract inputs list from schema.""" + inputs_list = [] + if isinstance(input_schema, dict) and "properties" in input_schema: + for field_name, field_schema in input_schema["properties"].items(): + inputs_list.append( + { + "name": field_name, + "title": field_schema.get("title", field_name), + "type": field_schema.get("type", "string"), + "description": field_schema.get("description", ""), + "required": field_name in input_schema.get("required", []), + } + ) + return inputs_list + + def _get_execution_modes(self, graph: GraphModel) -> list[str]: + """Get available execution modes for the graph.""" + trigger_info = graph.trigger_setup_info + if trigger_info is None: + return ["manual", "scheduled"] + return ["webhook"] + + def _build_inputs_message( + self, + graph: GraphModel, + suffix: str, + ) -> str: + """Build a message describing available inputs for an agent.""" + inputs_list = self._get_inputs_list(graph.input_schema) + required_names = [i["name"] for i in inputs_list if i["required"]] + optional_names = [i["name"] for i in inputs_list if not i["required"]] + + message_parts = [f"Agent '{graph.name}' accepts the following inputs:"] + if required_names: + message_parts.append(f"Required: {', '.join(required_names)}.") + if optional_names: + message_parts.append( + f"Optional (have defaults): {', '.join(optional_names)}." + ) + if not inputs_list: + message_parts = [f"Agent '{graph.name}' has no required inputs."] + message_parts.append(suffix) + + return " ".join(message_parts) + + def _build_agent_details( + self, + graph: GraphModel, + credentials: list[CredentialsMetaInput], + ) -> AgentDetails: + """Build AgentDetails from a graph.""" + trigger_info = ( + graph.trigger_setup_info.model_dump() if graph.trigger_setup_info else None + ) + return AgentDetails( + id=graph.id, + name=graph.name, + description=graph.description, + inputs=graph.input_schema, + credentials=credentials, + execution_options=ExecutionOptions( + manual=trigger_info is None, + scheduled=trigger_info is None, + webhook=trigger_info is not None, + ), + trigger_info=trigger_info, + ) + + async def _run_agent( + self, + user_id: str, + session: ChatSession, + graph: GraphModel, + graph_credentials: dict[str, CredentialsMetaInput], + inputs: dict[str, Any], + ) -> ToolResponseBase: + """Execute an agent immediately.""" + session_id = session.session_id + + # Check rate limits + if session.successful_agent_runs.get(graph.id, 0) >= config.max_agent_runs: + return ErrorResponse( + message="Maximum agent runs reached for this session. Please try again later.", + session_id=session_id, + ) + + # Get or create library agent + library_agent = await get_or_create_library_agent(graph, user_id) + + # Execute execution = await execution_utils.add_graph_execution( graph_id=library_agent.graph_id, user_id=user_id, inputs=inputs, - graph_credentials_inputs=graph_credentials_inputs, + graph_credentials_inputs=graph_credentials, ) + # Track successful run session.successful_agent_runs[library_agent.graph_id] = ( session.successful_agent_runs.get(library_agent.graph_id, 0) + 1 ) + library_agent_link = f"/library/agents/{library_agent.id}" return ExecutionStartedResponse( - message=f"Agent execution successfully started. You can add a link to the agent at: /library/agents/{library_agent.id}. Do not run this tool again unless specifically asked to run the agent again.", + message=( + f"Agent '{library_agent.name}' execution started successfully. " + f"View at {library_agent_link}. " + f"{MSG_DO_NOT_RUN_AGAIN}" + ), session_id=session_id, execution_id=execution.id, graph_id=library_agent.graph_id, graph_name=library_agent.name, + library_agent_id=library_agent.id, + library_agent_link=library_agent_link, + ) + + async def _schedule_agent( + self, + user_id: str, + session: ChatSession, + graph: GraphModel, + graph_credentials: dict[str, CredentialsMetaInput], + inputs: dict[str, Any], + schedule_name: str, + cron: str, + timezone: str, + ) -> ToolResponseBase: + """Set up scheduled execution for an agent.""" + session_id = session.session_id + + # Validate schedule params + if not schedule_name: + return ErrorResponse( + message="schedule_name is required for scheduled execution", + session_id=session_id, + ) + if not cron: + return ErrorResponse( + message="cron expression is required for scheduled execution", + session_id=session_id, + ) + + # Check rate limits + if ( + session.successful_agent_schedules.get(graph.id, 0) + >= config.max_agent_schedules + ): + return ErrorResponse( + message="Maximum agent schedules reached for this session.", + session_id=session_id, + ) + + # Get or create library agent + library_agent = await get_or_create_library_agent(graph, user_id) + + # Get user timezone + user = await get_user_by_id(user_id) + user_timezone = get_user_timezone_or_utc(user.timezone if user else timezone) + + # Create schedule + result = await get_scheduler_client().add_execution_schedule( + user_id=user_id, + graph_id=library_agent.graph_id, + graph_version=library_agent.graph_version, + name=schedule_name, + cron=cron, + input_data=inputs, + input_credentials=graph_credentials, + user_timezone=user_timezone, + ) + + # Convert next_run_time to user timezone for display + if result.next_run_time: + result.next_run_time = convert_utc_time_to_user_timezone( + result.next_run_time, user_timezone + ) + + # Track successful schedule + session.successful_agent_schedules[library_agent.graph_id] = ( + session.successful_agent_schedules.get(library_agent.graph_id, 0) + 1 + ) + + library_agent_link = f"/library/agents/{library_agent.id}" + return ExecutionStartedResponse( + message=( + f"Agent '{library_agent.name}' scheduled successfully as '{schedule_name}'. " + f"View at {library_agent_link}. " + f"{MSG_DO_NOT_SCHEDULE_AGAIN}" + ), + session_id=session_id, + execution_id=result.id, + graph_id=library_agent.graph_id, + graph_name=library_agent.name, + library_agent_id=library_agent.id, + library_agent_link=library_agent_link, ) diff --git a/autogpt_platform/backend/backend/server/v2/chat/tools/run_agent_test.py b/autogpt_platform/backend/backend/server/v2/chat/tools/run_agent_test.py index dc50bf1c3a..3ffd4a883e 100644 --- a/autogpt_platform/backend/backend/server/v2/chat/tools/run_agent_test.py +++ b/autogpt_platform/backend/backend/server/v2/chat/tools/run_agent_test.py @@ -5,6 +5,7 @@ import pytest from backend.server.v2.chat.tools._test_data import ( make_session, + setup_firecrawl_test_data, setup_llm_test_data, setup_test_data, ) @@ -13,6 +14,7 @@ from backend.server.v2.chat.tools.run_agent import RunAgentTool # This is so the formatter doesn't remove the fixture imports setup_llm_test_data = setup_llm_test_data setup_test_data = setup_test_data +setup_firecrawl_test_data = setup_firecrawl_test_data @pytest.mark.asyncio(scope="session") @@ -169,3 +171,221 @@ async def test_run_agent_with_llm_credentials(setup_llm_test_data): assert result_data["graph_id"] == graph.id assert "graph_name" in result_data assert result_data["graph_name"] == "LLM Test Agent" + + +@pytest.mark.asyncio(scope="session") +async def test_run_agent_shows_available_inputs_when_none_provided(setup_test_data): + """Test that run_agent returns available inputs when called without inputs or use_defaults.""" + user = setup_test_data["user"] + store_submission = setup_test_data["store_submission"] + + tool = RunAgentTool() + agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" + session = make_session(user_id=user.id) + + # Execute without inputs and without use_defaults + response = await tool.execute( + user_id=user.id, + session_id=str(uuid.uuid4()), + tool_call_id=str(uuid.uuid4()), + username_agent_slug=agent_marketplace_id, + inputs={}, + use_defaults=False, + session=session, + ) + + assert response is not None + assert hasattr(response, "result") + assert isinstance(response.result, str) + result_data = orjson.loads(response.result) + + # Should return agent_details type showing available inputs + assert result_data.get("type") == "agent_details" + assert "agent" in result_data + assert "message" in result_data + # Message should mention inputs + assert "inputs" in result_data["message"].lower() + + +@pytest.mark.asyncio(scope="session") +async def test_run_agent_with_use_defaults(setup_test_data): + """Test that run_agent executes successfully with use_defaults=True.""" + user = setup_test_data["user"] + graph = setup_test_data["graph"] + store_submission = setup_test_data["store_submission"] + + tool = RunAgentTool() + agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" + session = make_session(user_id=user.id) + + # Execute with use_defaults=True (no explicit inputs) + response = await tool.execute( + user_id=user.id, + session_id=str(uuid.uuid4()), + tool_call_id=str(uuid.uuid4()), + username_agent_slug=agent_marketplace_id, + inputs={}, + use_defaults=True, + session=session, + ) + + assert response is not None + assert hasattr(response, "result") + assert isinstance(response.result, str) + result_data = orjson.loads(response.result) + + # Should execute successfully + assert "execution_id" in result_data + assert result_data["graph_id"] == graph.id + + +@pytest.mark.asyncio(scope="session") +async def test_run_agent_missing_credentials(setup_firecrawl_test_data): + """Test that run_agent returns setup_requirements when credentials are missing.""" + user = setup_firecrawl_test_data["user"] + store_submission = setup_firecrawl_test_data["store_submission"] + + tool = RunAgentTool() + agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" + session = make_session(user_id=user.id) + + # Execute - user doesn't have firecrawl credentials + response = await tool.execute( + user_id=user.id, + session_id=str(uuid.uuid4()), + tool_call_id=str(uuid.uuid4()), + username_agent_slug=agent_marketplace_id, + inputs={"url": "https://example.com"}, + session=session, + ) + + assert response is not None + assert hasattr(response, "result") + assert isinstance(response.result, str) + result_data = orjson.loads(response.result) + + # Should return setup_requirements type with missing credentials + assert result_data.get("type") == "setup_requirements" + assert "setup_info" in result_data + setup_info = result_data["setup_info"] + assert "user_readiness" in setup_info + assert setup_info["user_readiness"]["has_all_credentials"] is False + assert len(setup_info["user_readiness"]["missing_credentials"]) > 0 + + +@pytest.mark.asyncio(scope="session") +async def test_run_agent_invalid_slug_format(setup_test_data): + """Test that run_agent returns error for invalid slug format (no slash).""" + user = setup_test_data["user"] + + tool = RunAgentTool() + session = make_session(user_id=user.id) + + # Execute with invalid slug format + response = await tool.execute( + user_id=user.id, + session_id=str(uuid.uuid4()), + tool_call_id=str(uuid.uuid4()), + username_agent_slug="no-slash-here", + inputs={}, + session=session, + ) + + assert response is not None + assert hasattr(response, "result") + assert isinstance(response.result, str) + result_data = orjson.loads(response.result) + + # Should return error + assert result_data.get("type") == "error" + assert "username/agent-name" in result_data["message"] + + +@pytest.mark.asyncio(scope="session") +async def test_run_agent_unauthenticated(): + """Test that run_agent returns need_login for unauthenticated users.""" + tool = RunAgentTool() + session = make_session(user_id=None) + + # Execute without user_id + response = await tool.execute( + user_id=None, + session_id=str(uuid.uuid4()), + tool_call_id=str(uuid.uuid4()), + username_agent_slug="test/test-agent", + inputs={}, + session=session, + ) + + assert response is not None + assert hasattr(response, "result") + assert isinstance(response.result, str) + result_data = orjson.loads(response.result) + + # Base tool returns need_login type for unauthenticated users + assert result_data.get("type") == "need_login" + assert "sign in" in result_data["message"].lower() + + +@pytest.mark.asyncio(scope="session") +async def test_run_agent_schedule_without_cron(setup_test_data): + """Test that run_agent returns error when scheduling without cron expression.""" + user = setup_test_data["user"] + store_submission = setup_test_data["store_submission"] + + tool = RunAgentTool() + agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" + session = make_session(user_id=user.id) + + # Try to schedule without cron + response = await tool.execute( + user_id=user.id, + session_id=str(uuid.uuid4()), + tool_call_id=str(uuid.uuid4()), + username_agent_slug=agent_marketplace_id, + inputs={"test_input": "test"}, + schedule_name="My Schedule", + cron="", # Empty cron + session=session, + ) + + assert response is not None + assert hasattr(response, "result") + assert isinstance(response.result, str) + result_data = orjson.loads(response.result) + + # Should return error about missing cron + assert result_data.get("type") == "error" + assert "cron" in result_data["message"].lower() + + +@pytest.mark.asyncio(scope="session") +async def test_run_agent_schedule_without_name(setup_test_data): + """Test that run_agent returns error when scheduling without schedule_name.""" + user = setup_test_data["user"] + store_submission = setup_test_data["store_submission"] + + tool = RunAgentTool() + agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" + session = make_session(user_id=user.id) + + # Try to schedule without schedule_name + response = await tool.execute( + user_id=user.id, + session_id=str(uuid.uuid4()), + tool_call_id=str(uuid.uuid4()), + username_agent_slug=agent_marketplace_id, + inputs={"test_input": "test"}, + schedule_name="", # Empty name + cron="0 9 * * *", + session=session, + ) + + assert response is not None + assert hasattr(response, "result") + assert isinstance(response.result, str) + result_data = orjson.loads(response.result) + + # Should return error about missing schedule_name + assert result_data.get("type") == "error" + assert "schedule_name" in result_data["message"].lower() diff --git a/autogpt_platform/backend/backend/server/v2/chat/tools/setup_agent.py b/autogpt_platform/backend/backend/server/v2/chat/tools/setup_agent.py deleted file mode 100644 index 5a2ddb8fbe..0000000000 --- a/autogpt_platform/backend/backend/server/v2/chat/tools/setup_agent.py +++ /dev/null @@ -1,395 +0,0 @@ -"""Tool for setting up an agent with credentials and configuration.""" - -import logging -from typing import Any - -from pydantic import BaseModel - -from backend.data.graph import get_graph -from backend.data.model import CredentialsMetaInput -from backend.data.user import get_user_by_id -from backend.integrations.creds_manager import IntegrationCredentialsManager -from backend.server.v2.chat.config import ChatConfig -from backend.server.v2.chat.model import ChatSession -from backend.server.v2.chat.tools.get_required_setup_info import ( - GetRequiredSetupInfoTool, -) -from backend.server.v2.chat.tools.models import ( - ExecutionStartedResponse, - SetupInfo, - SetupRequirementsResponse, -) -from backend.server.v2.library import db as library_db -from backend.server.v2.library import model as library_model -from backend.util.clients import get_scheduler_client -from backend.util.timezone_utils import ( - convert_utc_time_to_user_timezone, - get_user_timezone_or_utc, -) - -from .base import BaseTool -from .models import ErrorResponse, ToolResponseBase - -config = ChatConfig() -logger = logging.getLogger(__name__) - - -class AgentDetails(BaseModel): - graph_name: str - graph_id: str - graph_version: int - recommended_schedule_cron: str | None - required_credentials: dict[str, CredentialsMetaInput] - - -class SetupAgentTool(BaseTool): - """Tool for setting up an agent with scheduled execution or webhook triggers.""" - - @property - def name(self) -> str: - return "schedule_agent" - - @property - def description(self) -> str: - return """Set up an agent with credentials and configure it for scheduled execution or webhook triggers. - IMPORTANT: Before calling this tool, you MUST first call get_agent_details to determine what inputs are required. - - For SCHEDULED execution: - - Cron format: "minute hour day month weekday" (e.g., "0 9 * * 1-5" = 9am weekdays) - - Common patterns: "0 * * * *" (hourly), "0 0 * * *" (daily at midnight), "0 9 * * 1" (Mondays at 9am) - - Timezone: Use IANA timezone names like "America/New_York", "Europe/London", "Asia/Tokyo" - - The 'inputs' parameter must contain ALL required inputs from get_agent_details as a dictionary - - For WEBHOOK triggers: - - The agent will be triggered by external events - - Still requires all input values from get_agent_details""" - - @property - def parameters(self) -> dict[str, Any]: - return { - "type": "object", - "properties": { - "username_agent_slug": { - "type": "string", - "description": "The marketplace agent slug (e.g., 'username/agent-name')", - }, - "setup_type": { - "type": "string", - "enum": ["schedule", "webhook"], - "description": "Type of setup: 'schedule' for cron, 'webhook' for triggers.", - }, - "name": { - "type": "string", - "description": "Name for this setup/schedule (e.g., 'Daily Report', 'Weekly Summary')", - }, - "description": { - "type": "string", - "description": "Description of this setup", - }, - "cron": { - "type": "string", - "description": "Cron expression (5 fields: minute hour day month weekday). Examples: '0 9 * * 1-5' (9am weekdays), '*/30 * * * *' (every 30 min)", - }, - "timezone": { - "type": "string", - "description": "IANA timezone (e.g., 'America/New_York', 'Europe/London', 'UTC'). Defaults to UTC if not specified.", - }, - "inputs": { - "type": "object", - "description": 'REQUIRED: Dictionary with ALL required inputs from get_agent_details. Format: {"input_name": value}', - "additionalProperties": True, - }, - "webhook_config": { - "type": "object", - "description": "Webhook configuration (required if setup_type is 'webhook')", - "additionalProperties": True, - }, - }, - "required": ["username_agent_slug", "setup_type"], - } - - @property - def requires_auth(self) -> bool: - """This tool requires authentication.""" - return True - - async def _execute( - self, - user_id: str | None, - session: ChatSession, - **kwargs, - ) -> ToolResponseBase: - """Set up an agent with configuration. - - Args: - user_id: Authenticated user ID - session_id: Chat session ID - **kwargs: Setup parameters - - Returns: - JSON formatted setup result - - """ - assert ( - user_id is not None - ), "User ID is required to run an agent. Superclass enforces authentication." - - session_id = session.session_id - setup_type = kwargs.get("setup_type", "schedule").strip() - if setup_type != "schedule": - return ErrorResponse( - message="Only schedule setup is supported at this time", - session_id=session_id, - ) - else: - cron = kwargs.get("cron", "").strip() - cron_name = kwargs.get("name", "").strip() - if not cron or not cron_name: - return ErrorResponse( - message="Cron and name are required for schedule setup", - session_id=session_id, - ) - - username_agent_slug = kwargs.get("username_agent_slug", "").strip() - inputs = kwargs.get("inputs", {}) - - library_agent = await self._get_or_add_library_agent( - username_agent_slug, user_id, session, **kwargs - ) - - if not isinstance(library_agent, AgentDetails): - # library agent is an ErrorResponse - return library_agent - - if library_agent and ( - session.successful_agent_schedules.get(library_agent.graph_id, 0) - if isinstance(library_agent, AgentDetails) - else 0 >= config.max_agent_schedules - ): - return ErrorResponse( - message="Maximum number of agent schedules reached. You can't schedule this agent again in this chat session.", - session_id=session.session_id, - ) - # At this point we know the user is ready to run the agent - # Create the schedule for the agent - from backend.server.v2.library import db as library_db - - # Get the library agent model for scheduling - lib_agent = await library_db.get_library_agent_by_graph_id( - graph_id=library_agent.graph_id, user_id=user_id - ) - if not lib_agent: - return ErrorResponse( - message=f"Library agent not found for graph {library_agent.graph_id}", - session_id=session_id, - ) - - return await self._add_graph_execution_schedule( - library_agent=lib_agent, - user_id=user_id, - cron=cron, - name=cron_name, - inputs=inputs, - credentials=library_agent.required_credentials, - session=session, - ) - - async def _add_graph_execution_schedule( - self, - library_agent: library_model.LibraryAgent, - user_id: str, - cron: str, - name: str, - inputs: dict[str, Any], - credentials: dict[str, CredentialsMetaInput], - session: ChatSession, - **kwargs, - ) -> ExecutionStartedResponse | ErrorResponse: - # Use timezone from request if provided, otherwise fetch from user profile - user = await get_user_by_id(user_id) - user_timezone = get_user_timezone_or_utc(user.timezone if user else None) - session_id = session.session_id - # Map required credentials (schema field names) to actual user credential IDs - # credentials param contains CredentialsMetaInput with schema field names as keys - # We need to find the user's actual credentials that match the provider/type - creds_manager = IntegrationCredentialsManager() - user_credentials = await creds_manager.store.get_all_creds(user_id) - - # Build a mapping from schema field name -> actual credential ID - resolved_credentials: dict[str, CredentialsMetaInput] = {} - missing_credentials: list[str] = [] - - for field_name, cred_meta in credentials.items(): - # Find a matching credential from the user's credentials - matching_cred = next( - ( - c - for c in user_credentials - if c.provider == cred_meta.provider and c.type == cred_meta.type - ), - None, - ) - - if matching_cred: - # Use the actual credential ID instead of the schema field name - # Create a new CredentialsMetaInput with the actual credential ID - # but keep the same provider/type from the original meta - resolved_credentials[field_name] = CredentialsMetaInput( - id=matching_cred.id, - provider=cred_meta.provider, - type=cred_meta.type, - title=cred_meta.title, - ) - else: - missing_credentials.append( - f"{cred_meta.title} ({cred_meta.provider}/{cred_meta.type})" - ) - - if missing_credentials: - return ErrorResponse( - message=f"Cannot execute agent: missing {len(missing_credentials)} required credential(s). You need to call the get_required_setup_info tool to setup the credentials.", - session_id=session_id, - ) - - result = await get_scheduler_client().add_execution_schedule( - user_id=user_id, - graph_id=library_agent.graph_id, - graph_version=library_agent.graph_version, - name=name, - cron=cron, - input_data=inputs, - input_credentials=resolved_credentials, - user_timezone=user_timezone, - ) - - # Convert the next_run_time back to user timezone for display - if result.next_run_time: - result.next_run_time = convert_utc_time_to_user_timezone( - result.next_run_time, user_timezone - ) - - session.successful_agent_schedules[library_agent.graph_id] = ( - session.successful_agent_schedules.get(library_agent.graph_id, 0) + 1 - ) - - return ExecutionStartedResponse( - message=f"Agent execution successfully scheduled. You can add a link to the agent at: /library/agents/{library_agent.id}. Do not run this tool again unless specifically asked to run the agent again.", - session_id=session_id, - execution_id=result.id, - graph_id=library_agent.graph_id, - graph_name=library_agent.name, - ) - - async def _get_or_add_library_agent( - self, agent_id: str, user_id: str, session: ChatSession, **kwargs - ) -> AgentDetails | ErrorResponse: - # Call _execute directly since we're calling internally from another tool - session_id = session.session_id - response = await GetRequiredSetupInfoTool()._execute(user_id, session, **kwargs) - - if not isinstance(response, SetupRequirementsResponse): - return ErrorResponse( - message="Failed to get required setup information", - session_id=session_id, - ) - - setup_info = SetupInfo.model_validate(response.setup_info) - - if not setup_info.user_readiness.ready_to_run: - return ErrorResponse( - message=f"User is not ready to run the agent. User Readiness: {setup_info.user_readiness.model_dump_json()} Requirments: {setup_info.requirements}", - session_id=session_id, - ) - - # Get the graph using the graph_id and graph_version from the setup response - if not response.graph_id or not response.graph_version: - return ErrorResponse( - message=f"Graph information not available for {agent_id}", - session_id=session_id, - ) - - graph = await get_graph( - graph_id=response.graph_id, - version=response.graph_version, - user_id=None, # Public access for store graphs - include_subgraphs=True, - ) - if not graph: - return ErrorResponse( - message=f"Graph {agent_id} ({response.graph_id}v{response.graph_version}) not found", - session_id=session_id, - ) - - recommended_schedule_cron = graph.recommended_schedule_cron - - # Extract credentials from the JSON schema properties - credentials_input_schema = graph.credentials_input_schema - required_credentials: dict[str, CredentialsMetaInput] = {} - if ( - isinstance(credentials_input_schema, dict) - and "properties" in credentials_input_schema - ): - for cred_name, cred_schema in credentials_input_schema[ - "properties" - ].items(): - # Get provider from credentials_provider array or properties.provider.const - provider = "unknown" - if ( - "credentials_provider" in cred_schema - and cred_schema["credentials_provider"] - ): - provider = cred_schema["credentials_provider"][0] - elif ( - "properties" in cred_schema - and "provider" in cred_schema["properties"] - ): - provider = cred_schema["properties"]["provider"].get( - "const", "unknown" - ) - - # Get type from credentials_types array or properties.type.const - cred_type = "api_key" # Default - if ( - "credentials_types" in cred_schema - and cred_schema["credentials_types"] - ): - cred_type = cred_schema["credentials_types"][0] - elif ( - "properties" in cred_schema and "type" in cred_schema["properties"] - ): - cred_type = cred_schema["properties"]["type"].get( - "const", "api_key" - ) - - required_credentials[cred_name] = CredentialsMetaInput( - id=cred_name, - title=cred_schema.get("title", cred_name), - provider=provider, # type: ignore - type=cred_type, - ) - - # Check if we already have a library agent for this graph - existing_library_agent = await library_db.get_library_agent_by_graph_id( - graph_id=graph.id, user_id=user_id - ) - if not existing_library_agent: - # Now we need to add the graph to the users library - library_agents: list[library_model.LibraryAgent] = ( - await library_db.create_library_agent( - graph=graph, - user_id=user_id, - create_library_agents_for_sub_graphs=False, - ) - ) - assert len(library_agents) == 1, "Expected 1 library agent to be created" - library_agent = library_agents[0] - else: - library_agent = existing_library_agent - - return AgentDetails( - graph_name=graph.name, - graph_id=library_agent.graph_id, - graph_version=library_agent.graph_version, - recommended_schedule_cron=recommended_schedule_cron, - required_credentials=required_credentials, - ) diff --git a/autogpt_platform/backend/backend/server/v2/chat/tools/setup_agent_test.py b/autogpt_platform/backend/backend/server/v2/chat/tools/setup_agent_test.py deleted file mode 100644 index 0ca1880c0e..0000000000 --- a/autogpt_platform/backend/backend/server/v2/chat/tools/setup_agent_test.py +++ /dev/null @@ -1,422 +0,0 @@ -import uuid - -import orjson -import pytest - -from backend.server.v2.chat.tools._test_data import ( - make_session, - setup_llm_test_data, - setup_test_data, -) -from backend.server.v2.chat.tools.setup_agent import SetupAgentTool -from backend.util.clients import get_scheduler_client - -# This is so the formatter doesn't remove the fixture imports -setup_llm_test_data = setup_llm_test_data -setup_test_data = setup_test_data - - -@pytest.mark.asyncio(scope="session") -async def test_setup_agent_missing_cron(setup_test_data): - """Test error when cron is missing for schedule setup""" - # Use test data from fixture - user = setup_test_data["user"] - store_submission = setup_test_data["store_submission"] - - # Create the tool instance - tool = SetupAgentTool() - - # Build the session - session = make_session(user_id=user.id) - - # Build the proper marketplace agent_id format - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - # Execute without cron - response = await tool.execute( - user_id=user.id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - setup_type="schedule", - inputs={"test_input": "Hello World"}, - # Missing: cron and name - ) - - # Verify error response - assert response is not None - assert hasattr(response, "result") - - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - assert "message" in result_data - assert ( - "cron" in result_data["message"].lower() - or "name" in result_data["message"].lower() - ) - - -@pytest.mark.asyncio(scope="session") -async def test_setup_agent_webhook_not_supported(setup_test_data): - """Test error when webhook setup is attempted""" - # Use test data from fixture - user = setup_test_data["user"] - store_submission = setup_test_data["store_submission"] - - # Create the tool instance - tool = SetupAgentTool() - - # Build the session - session = make_session(user_id=user.id) - - # Build the proper marketplace agent_id format - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - # Execute with webhook setup_type - response = await tool.execute( - user_id=user.id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - setup_type="webhook", - inputs={"test_input": "Hello World"}, - ) - - # Verify error response - assert response is not None - assert hasattr(response, "result") - - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - assert "message" in result_data - message_lower = result_data["message"].lower() - assert "schedule" in message_lower and "supported" in message_lower - - -@pytest.mark.asyncio(scope="session") -@pytest.mark.skip(reason="Requires scheduler service to be running") -async def test_setup_agent_schedule_success(setup_test_data): - """Test successfully setting up an agent with a schedule""" - # Use test data from fixture - user = setup_test_data["user"] - store_submission = setup_test_data["store_submission"] - - # Create the tool instance - tool = SetupAgentTool() - - # Build the session - session = make_session(user_id=user.id) - - # Build the proper marketplace agent_id format - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - # Execute with schedule setup - response = await tool.execute( - user_id=user.id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - setup_type="schedule", - name="Test Schedule", - description="Test schedule description", - cron="0 9 * * *", # Daily at 9am - timezone="UTC", - inputs={"test_input": "Hello World"}, - ) - - # Verify the response - assert response is not None - assert hasattr(response, "result") - - # Parse the result JSON - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - - # Check for execution started - assert "message" in result_data - assert "execution_id" in result_data - assert "graph_id" in result_data - assert "graph_name" in result_data - - -@pytest.mark.asyncio(scope="session") -@pytest.mark.skip(reason="Requires scheduler service to be running") -async def test_setup_agent_with_credentials(setup_llm_test_data): - """Test setting up an agent that requires credentials""" - # Use test data from fixture (includes OpenAI credentials) - user = setup_llm_test_data["user"] - store_submission = setup_llm_test_data["store_submission"] - - # Create the tool instance - tool = SetupAgentTool() - - # Build the session - session = make_session(user_id=user.id) - - # Build the proper marketplace agent_id format - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - # Execute with schedule setup - response = await tool.execute( - user_id=user.id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - setup_type="schedule", - name="LLM Schedule", - description="LLM schedule with credentials", - cron="*/30 * * * *", # Every 30 minutes - timezone="America/New_York", - inputs={"user_prompt": "What is 2+2?"}, - ) - - # Verify the response - assert response is not None - assert hasattr(response, "result") - - # Parse the result JSON - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - - # Should succeed since user has OpenAI credentials - assert "execution_id" in result_data - assert "graph_id" in result_data - - -@pytest.mark.asyncio(scope="session") -async def test_setup_agent_invalid_agent(setup_test_data): - """Test error when agent doesn't exist""" - # Use test data from fixture - user = setup_test_data["user"] - - # Create the tool instance - tool = SetupAgentTool() - - # Build the session - session = make_session(user_id=user.id) - - # Execute with non-existent agent - response = await tool.execute( - user_id=user.id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug="nonexistent/agent", - setup_type="schedule", - name="Test Schedule", - cron="0 9 * * *", - inputs={}, - ) - - # Verify error response - assert response is not None - assert hasattr(response, "result") - - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - assert "message" in result_data - # Should fail to find the agent - assert any( - phrase in result_data["message"].lower() - for phrase in ["not found", "failed", "error"] - ) - - -@pytest.mark.asyncio(scope="session") -@pytest.mark.skip(reason="Requires scheduler service to be running") -async def test_setup_agent_schedule_created_in_scheduler(setup_test_data): - """Test that the schedule is actually created in the scheduler service""" - # Use test data from fixture - user = setup_test_data["user"] - graph = setup_test_data["graph"] - store_submission = setup_test_data["store_submission"] - - # Create the tool instance - tool = SetupAgentTool() - - # Build the session - session = make_session(user_id=user.id) - - # Build the proper marketplace agent_id format - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - # Create a unique schedule name to identify this test - schedule_name = f"Test Schedule {uuid.uuid4()}" - - # Execute with schedule setup - response = await tool.execute( - user_id=user.id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - setup_type="schedule", - name=schedule_name, - description="Test schedule to verify credentials", - cron="0 0 * * *", # Daily at midnight - timezone="UTC", - inputs={"test_input": "Scheduled execution"}, - ) - - # Verify the response - assert response is not None - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - assert "execution_id" in result_data - - # Now verify the schedule was created in the scheduler service - scheduler = get_scheduler_client() - schedules = await scheduler.get_execution_schedules(graph.id, user.id) - - # Find our schedule - our_schedule = None - for schedule in schedules: - if schedule.name == schedule_name: - our_schedule = schedule - break - - assert ( - our_schedule is not None - ), f"Schedule '{schedule_name}' not found in scheduler" - assert our_schedule.cron == "0 0 * * *" - assert our_schedule.graph_id == graph.id - - # Clean up: delete the schedule - await scheduler.delete_schedule(our_schedule.id, user_id=user.id) - - -@pytest.mark.asyncio(scope="session") -@pytest.mark.skip(reason="Requires scheduler service to be running") -async def test_setup_agent_schedule_with_credentials_triggered(setup_llm_test_data): - """Test that credentials are properly passed when a schedule is triggered""" - # Use test data from fixture (includes OpenAI credentials) - user = setup_llm_test_data["user"] - graph = setup_llm_test_data["graph"] - store_submission = setup_llm_test_data["store_submission"] - - # Create the tool instance - tool = SetupAgentTool() - - # Build the session - session = make_session(user_id=user.id) - - # Build the proper marketplace agent_id format - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - # Create a unique schedule name - schedule_name = f"LLM Test Schedule {uuid.uuid4()}" - - # Execute with schedule setup - response = await tool.execute( - user_id=user.id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - setup_type="schedule", - name=schedule_name, - description="Test LLM schedule with credentials", - cron="* * * * *", # Every minute (for testing) - timezone="UTC", - inputs={"user_prompt": "Test prompt for credentials"}, - ) - - # Verify the response - assert response is not None - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - assert "execution_id" in result_data - - # Get the schedule from the scheduler - scheduler = get_scheduler_client() - schedules = await scheduler.get_execution_schedules(graph.id, user.id) - - # Find our schedule - our_schedule = None - for schedule in schedules: - if schedule.name == schedule_name: - our_schedule = schedule - break - - assert our_schedule is not None, f"Schedule '{schedule_name}' not found" - - # Verify the schedule has the correct input data - assert our_schedule.input_data is not None - assert "user_prompt" in our_schedule.input_data - assert our_schedule.input_data["user_prompt"] == "Test prompt for credentials" - - # Verify credentials are stored in the schedule - # The credentials should be stored as input_credentials - assert our_schedule.input_credentials is not None - - # The credentials should contain the OpenAI provider credential - # Note: The exact structure depends on how credentials are serialized - # We're checking that credentials data exists and has the right provider - if our_schedule.input_credentials: - # Convert to dict if needed - creds_dict = ( - our_schedule.input_credentials - if isinstance(our_schedule.input_credentials, dict) - else {} - ) - - # Check if any credential has openai provider - has_openai_cred = False - for cred_key, cred_value in creds_dict.items(): - if isinstance(cred_value, dict): - if cred_value.get("provider") == "openai": - has_openai_cred = True - # Verify the credential has the expected structure - assert "id" in cred_value or "api_key" in cred_value - break - - # If we have LLM block, we should have stored credentials - assert has_openai_cred, "OpenAI credentials not found in schedule" - - # Clean up: delete the schedule - await scheduler.delete_schedule(our_schedule.id, user_id=user.id) - - -@pytest.mark.asyncio(scope="session") -@pytest.mark.skip(reason="Requires scheduler service to be running") -async def test_setup_agent_creates_library_agent(setup_test_data): - """Test that setup creates a library agent for the user""" - # Use test data from fixture - user = setup_test_data["user"] - graph = setup_test_data["graph"] - store_submission = setup_test_data["store_submission"] - - # Create the tool instance - tool = SetupAgentTool() - - # Build the session - session = make_session(user_id=user.id) - - # Build the proper marketplace agent_id format - agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}" - - # Execute with schedule setup - response = await tool.execute( - user_id=user.id, - session=session, - tool_call_id=str(uuid.uuid4()), - username_agent_slug=agent_marketplace_id, - setup_type="schedule", - name="Library Test Schedule", - cron="0 12 * * *", # Daily at noon - inputs={"test_input": "Library test"}, - ) - - # Verify the response - assert response is not None - assert isinstance(response.result, str) - result_data = orjson.loads(response.result) - assert "graph_id" in result_data - assert result_data["graph_id"] == graph.id - - # Verify library agent was created - from backend.server.v2.library import db as library_db - - library_agent = await library_db.get_library_agent_by_graph_id( - graph_id=graph.id, user_id=user.id - ) - assert library_agent is not None - assert library_agent.graph_id == graph.id - assert library_agent.name == "Test Agent" diff --git a/autogpt_platform/backend/backend/server/v2/chat/tools/utils.py b/autogpt_platform/backend/backend/server/v2/chat/tools/utils.py new file mode 100644 index 0000000000..ef4bc6f272 --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/chat/tools/utils.py @@ -0,0 +1,288 @@ +"""Shared utilities for chat tools.""" + +import logging +from typing import Any + +from backend.data import graph as graph_db +from backend.data.graph import GraphModel +from backend.data.model import CredentialsMetaInput +from backend.integrations.creds_manager import IntegrationCredentialsManager +from backend.server.v2.library import db as library_db +from backend.server.v2.library import model as library_model +from backend.server.v2.store import db as store_db +from backend.util.exceptions import NotFoundError + +logger = logging.getLogger(__name__) + + +async def fetch_graph_from_store_slug( + username: str, + agent_name: str, +) -> tuple[GraphModel | None, Any | None]: + """ + Fetch graph from store by username/agent_name slug. + + Args: + username: Creator's username + agent_name: Agent name/slug + + Returns: + tuple[Graph | None, StoreAgentDetails | None]: The graph and store agent details, + or (None, None) if not found. + + Raises: + DatabaseError: If there's a database error during lookup. + """ + try: + store_agent = await store_db.get_store_agent_details(username, agent_name) + except NotFoundError: + return None, None + + # Get the graph from store listing version + graph_meta = await store_db.get_available_graph( + store_agent.store_listing_version_id + ) + graph = await graph_db.get_graph( + graph_id=graph_meta.id, + version=graph_meta.version, + user_id=None, # Public access + include_subgraphs=True, + ) + return graph, store_agent + + +def extract_credentials_from_schema( + credentials_input_schema: dict[str, Any] | None, +) -> list[CredentialsMetaInput]: + """ + Extract credential requirements from graph's credentials_input_schema. + + This consolidates duplicated logic from get_agent_details.py and setup_agent.py. + + Args: + credentials_input_schema: The credentials_input_schema from a Graph object + + Returns: + List of CredentialsMetaInput with provider and type info + """ + credentials: list[CredentialsMetaInput] = [] + + if ( + not isinstance(credentials_input_schema, dict) + or "properties" not in credentials_input_schema + ): + return credentials + + for cred_name, cred_schema in credentials_input_schema["properties"].items(): + provider = _extract_provider_from_schema(cred_schema) + cred_type = _extract_credential_type_from_schema(cred_schema) + + credentials.append( + CredentialsMetaInput( + id=cred_name, + title=cred_schema.get("title", cred_name), + provider=provider, # type: ignore + type=cred_type, # type: ignore + ) + ) + + return credentials + + +def extract_credentials_as_dict( + credentials_input_schema: dict[str, Any] | None, +) -> dict[str, CredentialsMetaInput]: + """ + Extract credential requirements as a dict keyed by field name. + + Args: + credentials_input_schema: The credentials_input_schema from a Graph object + + Returns: + Dict mapping field name to CredentialsMetaInput + """ + credentials: dict[str, CredentialsMetaInput] = {} + + if ( + not isinstance(credentials_input_schema, dict) + or "properties" not in credentials_input_schema + ): + return credentials + + for cred_name, cred_schema in credentials_input_schema["properties"].items(): + provider = _extract_provider_from_schema(cred_schema) + cred_type = _extract_credential_type_from_schema(cred_schema) + + credentials[cred_name] = CredentialsMetaInput( + id=cred_name, + title=cred_schema.get("title", cred_name), + provider=provider, # type: ignore + type=cred_type, # type: ignore + ) + + return credentials + + +def _extract_provider_from_schema(cred_schema: dict[str, Any]) -> str: + """Extract provider from credential schema.""" + if "credentials_provider" in cred_schema and cred_schema["credentials_provider"]: + return cred_schema["credentials_provider"][0] + if "properties" in cred_schema and "provider" in cred_schema["properties"]: + return cred_schema["properties"]["provider"].get("const", "unknown") + return "unknown" + + +def _extract_credential_type_from_schema(cred_schema: dict[str, Any]) -> str: + """Extract credential type from credential schema.""" + if "credentials_types" in cred_schema and cred_schema["credentials_types"]: + return cred_schema["credentials_types"][0] + if "properties" in cred_schema and "type" in cred_schema["properties"]: + return cred_schema["properties"]["type"].get("const", "api_key") + return "api_key" + + +async def get_or_create_library_agent( + graph: GraphModel, + user_id: str, +) -> library_model.LibraryAgent: + """ + Get existing library agent or create new one. + + This consolidates duplicated logic from run_agent.py and setup_agent.py. + + Args: + graph: The Graph to add to library + user_id: The user's ID + + Returns: + LibraryAgent instance + """ + existing = await library_db.get_library_agent_by_graph_id( + graph_id=graph.id, user_id=user_id + ) + if existing: + return existing + + library_agents = await library_db.create_library_agent( + graph=graph, + user_id=user_id, + create_library_agents_for_sub_graphs=False, + ) + assert len(library_agents) == 1, "Expected 1 library agent to be created" + return library_agents[0] + + +async def match_user_credentials_to_graph( + user_id: str, + graph: GraphModel, +) -> tuple[dict[str, CredentialsMetaInput], list[str]]: + """ + Match user's available credentials against graph's required credentials. + + Uses graph.aggregate_credentials_inputs() which handles credentials from + multiple nodes and uses frozensets for provider matching. + + Args: + user_id: The user's ID + graph: The Graph with credential requirements + + Returns: + tuple[matched_credentials dict, missing_credential_descriptions list] + """ + graph_credentials_inputs: dict[str, CredentialsMetaInput] = {} + missing_creds: list[str] = [] + + # Get aggregated credentials requirements from the graph + aggregated_creds = graph.aggregate_credentials_inputs() + logger.debug( + f"Matching credentials for graph {graph.id}: {len(aggregated_creds)} required" + ) + + if not aggregated_creds: + return graph_credentials_inputs, missing_creds + + # Get all available credentials for the user + creds_manager = IntegrationCredentialsManager() + available_creds = await creds_manager.store.get_all_creds(user_id) + + # For each required credential field, find a matching user credential + # field_info.provider is a frozenset because aggregate_credentials_inputs() + # combines requirements from multiple nodes. A credential matches if its + # provider is in the set of acceptable providers. + for credential_field_name, ( + credential_requirements, + _node_fields, + ) in aggregated_creds.items(): + # Find first matching credential by provider and type + matching_cred = next( + ( + cred + for cred in available_creds + if cred.provider in credential_requirements.provider + and cred.type in credential_requirements.supported_types + ), + None, + ) + + if matching_cred: + try: + graph_credentials_inputs[credential_field_name] = CredentialsMetaInput( + id=matching_cred.id, + provider=matching_cred.provider, # type: ignore + type=matching_cred.type, + title=matching_cred.title, + ) + except Exception as e: + logger.error( + f"Failed to create CredentialsMetaInput for field '{credential_field_name}': " + f"provider={matching_cred.provider}, type={matching_cred.type}, " + f"credential_id={matching_cred.id}", + exc_info=True, + ) + missing_creds.append( + f"{credential_field_name} (validation failed: {e})" + ) + else: + missing_creds.append( + f"{credential_field_name} " + f"(requires provider in {list(credential_requirements.provider)}, " + f"type in {list(credential_requirements.supported_types)})" + ) + + logger.info( + f"Credential matching complete: {len(graph_credentials_inputs)}/{len(aggregated_creds)} matched" + ) + + return graph_credentials_inputs, missing_creds + + +async def check_user_has_required_credentials( + user_id: str, + required_credentials: list[CredentialsMetaInput], +) -> list[CredentialsMetaInput]: + """ + Check which required credentials the user is missing. + + Args: + user_id: The user's ID + required_credentials: List of required credentials + + Returns: + List of missing credentials (empty if user has all) + """ + if not required_credentials: + return [] + + creds_manager = IntegrationCredentialsManager() + available_creds = await creds_manager.store.get_all_creds(user_id) + + missing: list[CredentialsMetaInput] = [] + for required in required_credentials: + has_matching = any( + cred.provider == required.provider and cred.type == required.type + for cred in available_creds + ) + if not has_matching: + missing.append(required) + + return missing diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/helpers.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/helpers.ts index 0ef2ed81ab..3a94dab1ea 100644 --- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/helpers.ts +++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/helpers.ts @@ -159,8 +159,9 @@ export function parseToolResponse( type: "execution_started", toolName: "execution_started", executionId: (parsedResult.execution_id as string) || "", - agentName: parsedResult.agent_name as string | undefined, + agentName: (parsedResult.graph_name as string) || undefined, message: parsedResult.message as string | undefined, + libraryAgentLink: parsedResult.library_agent_link as string | undefined, timestamp: timestamp || new Date(), }; } @@ -263,7 +264,7 @@ export function extractCredentialsNeeded( })); return { type: "credentials_needed", - toolName: "get_required_setup_info", + toolName: "run_agent", credentials, message: `To run ${agentName}, you need to add ${credentials.length === 1 ? "credentials" : `${credentials.length} credentials`}.`, agentName, diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.handlers.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.handlers.ts index 4cca79daca..fdbecb5d61 100644 --- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.handlers.ts +++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatContainer/useChatContainer.handlers.ts @@ -100,9 +100,9 @@ export function handleToolResponse( parsedResult = null; } if ( - chunk.tool_name === "get_required_setup_info" && + chunk.tool_name === "run_agent" && chunk.success && - parsedResult + parsedResult?.type === "setup_requirements" ) { const credentialsMessage = extractCredentialsNeeded(parsedResult); if (credentialsMessage) { diff --git a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatMessage/useChatMessage.ts b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatMessage/useChatMessage.ts index 983c166ecb..ae4f48f35b 100644 --- a/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatMessage/useChatMessage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/chat/components/ChatMessage/useChatMessage.ts @@ -75,6 +75,7 @@ export type ChatMessageData = executionId: string; agentName?: string; message?: string; + libraryAgentLink?: string; timestamp?: string | Date; };