From fd3d05ff8400ab0a92955993bedfcc38a0c287ef Mon Sep 17 00:00:00 2001 From: ahmetgunduz Date: Fri, 20 Jun 2025 13:56:14 +0300 Subject: [PATCH 1/2] ENG-2316: Deploy tools and agents automatically --- aixplain/modules/mixins.py | 7 ++ .../team_agent/team_agent_functional_test.py | 70 ++++++++++++++++++ tests/unit/team_agent/team_agent_test.py | 74 +++++++++++++------ 3 files changed, 129 insertions(+), 22 deletions(-) diff --git a/aixplain/modules/mixins.py b/aixplain/modules/mixins.py index 27c023c7..28d93c09 100644 --- a/aixplain/modules/mixins.py +++ b/aixplain/modules/mixins.py @@ -18,6 +18,7 @@ Description: Mixins for common functionality across different asset types """ + from abc import ABC from typing import TypeVar, Generic from aixplain.enums import AssetStatus @@ -67,8 +68,14 @@ def deploy(self) -> None: self._validate_deployment_readiness() previous_status = self.status try: + # Deploy tools if present if hasattr(self, "tools"): [tool.deploy() for tool in self.tools] + + # Deploy agents if present (for TeamAgent) + if hasattr(self, "agents"): + [agent.deploy() for agent in self.agents] + self.status = AssetStatus.ONBOARDED self.update() except Exception as e: diff --git a/tests/functional/team_agent/team_agent_functional_test.py b/tests/functional/team_agent/team_agent_functional_test.py index f3852f60..ce78be43 100644 --- a/tests/functional/team_agent/team_agent_functional_test.py +++ b/tests/functional/team_agent/team_agent_functional_test.py @@ -108,6 +108,76 @@ def test_draft_team_agent_update(run_input_map, TeamAgentFactory): assert team_agent.status == AssetStatus.DRAFT +@pytest.mark.parametrize("TeamAgentFactory", [TeamAgentFactory, v2.TeamAgent]) +def test_nested_deployment_chain(delete_agents_and_team_agents, TeamAgentFactory): + """Test that deploying a team agent properly deploys all nested components (tools -> agents -> team)""" + assert delete_agents_and_team_agents + + # Create first agent with translation tool (in DRAFT state) + translation_function = Function.TRANSLATION + function_params = translation_function.get_parameters() + function_params.targetlanguage = "es" + function_params.sourcelanguage = "en" + translation_tool = AgentFactory.create_model_tool( + function=translation_function, + description="Translation tool from English to Spanish", + supplier="microsoft", + ) + + translation_agent = AgentFactory.create( + name="Translation Agent", + description="Agent for translation", + instructions="Translate text from English to Spanish", + llm_id="6646261c6eb563165658bbb1", + tools=[translation_tool], + ) + assert translation_agent.status == AssetStatus.DRAFT + # Create second agent with text generation tool (in DRAFT state) + text_gen_tool = AgentFactory.create_model_tool( + function=Function.TEXT_GENERATION, + description="Text generation tool", + supplier="openai", + ) + + text_gen_agent = AgentFactory.create( + name="Text Generation Agent", + description="Agent for text generation", + instructions="Generate creative text based on input", + llm_id="6646261c6eb563165658bbb1", + tools=[text_gen_tool], + ) + assert text_gen_agent.status == AssetStatus.DRAFT + + # Create team agent with both agents (in DRAFT state) + team_agent = TeamAgentFactory.create( + name="Multi-Function Team", + description="Team that can translate and generate text", + agents=[translation_agent, text_gen_agent], + llm_id="6646261c6eb563165658bbb1", + ) + assert team_agent.status == AssetStatus.DRAFT + for agent in team_agent.agents: + assert agent.status == AssetStatus.DRAFT + + # Deploy team agent - this should trigger deployment of all nested components + team_agent.deploy() + + # Verify team agent is deployed + team_agent = TeamAgentFactory.get(team_agent.id) + assert team_agent.status == AssetStatus.ONBOARDED + + # Verify all agents are deployed + for agent in team_agent.agents: + agent_obj = AgentFactory.get(agent.id) + assert agent_obj.status == AssetStatus.ONBOARDED + # Verify all tools are deployed + for tool in agent_obj.tools: + assert tool.status == AssetStatus.ONBOARDED + + # Clean up + team_agent.delete() + + @pytest.mark.parametrize("TeamAgentFactory", [TeamAgentFactory, v2.TeamAgent]) def test_fail_non_existent_llm(run_input_map, TeamAgentFactory): for team in TeamAgentFactory.list()["results"]: diff --git a/tests/unit/team_agent/team_agent_test.py b/tests/unit/team_agent/team_agent_test.py index 5a154d0b..72371745 100644 --- a/tests/unit/team_agent/team_agent_test.py +++ b/tests/unit/team_agent/team_agent_test.py @@ -149,7 +149,7 @@ def test_create_team_agent(mock_model_factory_get): "function": {"id": "text-generation"}, "supplier": "openai", "version": {"id": "1.0"}, - "status": "onboarded", + "status": "draft", "pricing": {"currency": "USD", "value": 0.0}, } mock.get(url, headers=headers, json=model_ref_response) @@ -163,7 +163,7 @@ def test_create_team_agent(mock_model_factory_get): "role": "Test Agent Role", "teamId": "123", "version": "1.0", - "status": "onboarded", + "status": "draft", "llmId": "6646261c6eb563165658bbb1", "pricing": {"currency": "USD", "value": 0.0}, "assets": [ @@ -215,8 +215,6 @@ def test_create_team_agent(mock_model_factory_get): llm_id="6646261c6eb563165658bbb1", description="TEST Multi agent", use_mentalist=True, - # TODO: inspectors=[Inspector(name="Test Inspector", model_id="6646261c6eb563165658bbb1", model_params={"prompt": "Test Prompt"}, policy=InspectorPolicy.ADAPTIVE)], - # TODO: inspector_targets=[InspectorTarget.STEPS, InspectorTarget.OUTPUT], ) assert team_agent.id is not None assert team_agent.name == team_ref_response["name"] @@ -227,27 +225,24 @@ def test_create_team_agent(mock_model_factory_get): assert len(team_agent.agents) == 1 assert team_agent.agents[0].id == team_ref_response["agents"][0]["assetId"] + # Mock deployment responses + # Mock agent deployment + url = urljoin(config.BACKEND_URL, f"sdk/agents/{agent.id}") + deployed_agent_response = ref_response.copy() + deployed_agent_response["status"] = "onboarded" + mock.put(url, headers=headers, json=deployed_agent_response) + mock.get(url, headers=headers, json=deployed_agent_response) + + # Mock team agent deployment url = urljoin(config.BACKEND_URL, f"sdk/agent-communities/{team_agent.id}") - team_ref_response = { - "id": "team_agent_123", - "name": "TEST Multi agent(-)", - "status": "onboarded", - "teamId": 645, - "description": "TEST Multi agent", - "llmId": "6646261c6eb563165658bbb1", - "assets": [], - "agents": [{"assetId": "123", "type": "AGENT", "number": 0, "label": "AGENT"}], - "links": [], - "plannerId": "6646261c6eb563165658bbb1", - "inspectorId": "6646261c6eb563165658bbb1", - "supervisorId": "6646261c6eb563165658bbb1", - "createdAt": "2024-10-28T19:30:25.344Z", - "updatedAt": "2024-10-28T19:30:25.344Z", - } - mock.put(url, headers=headers, json=team_ref_response) + deployed_team_response = team_ref_response.copy() + deployed_team_response["status"] = "onboarded" + mock.put(url, headers=headers, json=deployed_team_response) + # Deploy and verify team_agent.deploy() - assert team_agent.status.value == "onboarded" + assert team_agent.status == AssetStatus.ONBOARDED + assert team_agent.agents[0].status == AssetStatus.ONBOARDED def test_fail_inspector_without_mentalist(): @@ -388,3 +383,38 @@ def test_deploy_team_agent(): # Verify that status was updated and update was called assert team_agent.status == AssetStatus.ONBOARDED team_agent.update.assert_called_once() + + +def test_deploy_team_agent_with_nested_agents(): + """Test that deploying a team agent properly deploys its nested agents.""" + # Create mock agents + mock_agent1 = Mock() + mock_agent1.id = "agent-1" + mock_agent1.name = "Test Agent 1" + mock_agent1.status = AssetStatus.DRAFT + mock_agent1.deploy = Mock() + + mock_agent2 = Mock() + mock_agent2.id = "agent-2" + mock_agent2.name = "Test Agent 2" + mock_agent2.status = AssetStatus.DRAFT + mock_agent2.deploy = Mock() + + # Create the team agent + team_agent = TeamAgent( + id="team-agent-id", name="Test Team Agent", agents=[mock_agent1, mock_agent2], status=AssetStatus.DRAFT + ) + + # Mock the update method + team_agent.update = Mock() + + # Deploy the team agent + team_agent.deploy() + + # Verify that each agent's deploy method was called + mock_agent1.deploy.assert_called_once() + mock_agent2.deploy.assert_called_once() + + # Verify that status was updated and update was called + assert team_agent.status == AssetStatus.ONBOARDED + team_agent.update.assert_called_once() From a138c5d013138d423bc2ef61d9f9c334c4fcd5ff Mon Sep 17 00:00:00 2001 From: ahmetgunduz Date: Tue, 24 Jun 2025 15:44:52 +0300 Subject: [PATCH 2/2] PROD-1816: added already deployed error for handling shared and deployed agents --- aixplain/exceptions/__init__.py | 14 ++++ aixplain/exceptions/types.py | 14 ++++ aixplain/modules/mixins.py | 18 ++++- .../team_agent/team_agent_functional_test.py | 74 +++++++++++++++++++ 4 files changed, 118 insertions(+), 2 deletions(-) diff --git a/aixplain/exceptions/__init__.py b/aixplain/exceptions/__init__.py index 5d645c62..1d430d83 100644 --- a/aixplain/exceptions/__init__.py +++ b/aixplain/exceptions/__init__.py @@ -15,8 +15,22 @@ NetworkError, ServiceError, InternalError, + AlreadyDeployedError, ) +__all__ = [ + "AixplainBaseException", + "AuthenticationError", + "ValidationError", + "ResourceError", + "BillingError", + "SupplierError", + "NetworkError", + "ServiceError", + "InternalError", + "AlreadyDeployedError", +] + def get_error_from_status_code(status_code: int, error_details: str = None) -> AixplainBaseException: """ diff --git a/aixplain/exceptions/types.py b/aixplain/exceptions/types.py index 56c710b5..133e5595 100644 --- a/aixplain/exceptions/types.py +++ b/aixplain/exceptions/types.py @@ -218,3 +218,17 @@ def __init__(self, message: str, **kwargs): error_code=ErrorCode.AX_INT_ERROR, **kwargs, ) + + +class AlreadyDeployedError(AixplainBaseException): + """Raised when an asset is already deployed.""" + + def __init__(self, message: str, **kwargs): + super().__init__( + message=message, + category=ErrorCategory.INTERNAL, + severity=ErrorSeverity.ERROR, + retry_recommended=kwargs.pop("retry_recommended", False), + error_code=ErrorCode.AX_INT_ERROR, + **kwargs, + ) diff --git a/aixplain/modules/mixins.py b/aixplain/modules/mixins.py index 28d93c09..8b3b3723 100644 --- a/aixplain/modules/mixins.py +++ b/aixplain/modules/mixins.py @@ -22,6 +22,7 @@ from abc import ABC from typing import TypeVar, Generic from aixplain.enums import AssetStatus +from aixplain.exceptions import AlreadyDeployedError T = TypeVar("T") @@ -51,7 +52,7 @@ def _validate_deployment_readiness(self) -> None: """ asset_type = self.__class__.__name__ if self.status == AssetStatus.ONBOARDED: - raise ValueError(f"{asset_type} is already deployed.") + raise AlreadyDeployedError(f"{asset_type} is already deployed.") if self.status != AssetStatus.DRAFT: raise ValueError(f"{asset_type} must be in DRAFT status to be deployed.") @@ -71,10 +72,23 @@ def deploy(self) -> None: # Deploy tools if present if hasattr(self, "tools"): [tool.deploy() for tool in self.tools] + for tool in self.tools: + try: + tool.deploy() + except AlreadyDeployedError: + pass + except Exception as e: + raise Exception(f"Error deploying tool {tool.name}: {e}") from e # Deploy agents if present (for TeamAgent) if hasattr(self, "agents"): - [agent.deploy() for agent in self.agents] + for agent in self.agents: + try: + agent.deploy() + except AlreadyDeployedError: + pass + except Exception as e: + raise Exception(f"Error deploying agent {agent.name}: {e}") from e self.status = AssetStatus.ONBOARDED self.update() diff --git a/tests/functional/team_agent/team_agent_functional_test.py b/tests/functional/team_agent/team_agent_functional_test.py index ce78be43..ddcb8188 100644 --- a/tests/functional/team_agent/team_agent_functional_test.py +++ b/tests/functional/team_agent/team_agent_functional_test.py @@ -559,3 +559,77 @@ def test_team_agent_with_slack_connector(): team_agent.delete() agent.delete() connection.delete() + + +@pytest.mark.parametrize("TeamAgentFactory", [TeamAgentFactory, v2.TeamAgent]) +def test_multiple_teams_with_shared_deployed_agent(delete_agents_and_team_agents, TeamAgentFactory): + """Test that multiple team agents can share the same deployed agent without name conflicts""" + assert delete_agents_and_team_agents + + # Create and deploy a shared agent first + translation_tool = AgentFactory.create_model_tool( + function=Function.TRANSLATION, + description="Translation tool from English to Spanish", + supplier="microsoft", + ) + + shared_agent = AgentFactory.create( + name="Shared Translation Agent", + description="Agent for translation shared between teams", + instructions="Translate text from English to Spanish", + llm_id="6646261c6eb563165658bbb1", + tools=[translation_tool], + ) + + # Deploy the shared agent first + shared_agent.deploy() + shared_agent = AgentFactory.get(shared_agent.id) + assert shared_agent.status == AssetStatus.ONBOARDED + + # Create first team agent with the shared agent + team_agent_1 = TeamAgentFactory.create( + name="Team Agent 1", + description="First team using shared agent", + agents=[shared_agent], + llm_id="6646261c6eb563165658bbb1", + ) + assert team_agent_1.status == AssetStatus.DRAFT + + # Deploy first team agent - should succeed without trying to redeploy the shared agent + team_agent_1.deploy() + team_agent_1 = TeamAgentFactory.get(team_agent_1.id) + assert team_agent_1.status == AssetStatus.ONBOARDED + + # Create second team agent with the same shared agent + team_agent_2 = TeamAgentFactory.create( + name="Team Agent 2", + description="Second team using shared agent", + agents=[shared_agent], + llm_id="6646261c6eb563165658bbb1", + ) + assert team_agent_2.status == AssetStatus.DRAFT + + # Deploy second team agent - should succeed without trying to redeploy the shared agent + # This should NOT throw a name_already_exists error + team_agent_2.deploy() + team_agent_2 = TeamAgentFactory.get(team_agent_2.id) + assert team_agent_2.status == AssetStatus.ONBOARDED + + # Verify both team agents are deployed and functional + response_1 = team_agent_1.run(data="Hello world") + assert response_1 is not None + assert response_1["completed"] is True + assert response_1["status"].lower() == "success" + + response_2 = team_agent_2.run(data="Hello world") + assert response_2 is not None + assert response_2["completed"] is True + assert response_2["status"].lower() == "success" + + # Verify the shared agent is still deployed and accessible + shared_agent_refreshed = AgentFactory.get(shared_agent.id) + assert shared_agent_refreshed.status == AssetStatus.ONBOARDED + + # Clean up + team_agent_1.delete() + team_agent_2.delete()