diff --git a/src/aks-agent/HISTORY.rst b/src/aks-agent/HISTORY.rst index 1cebda08a46..57f3ff8a2da 100644 --- a/src/aks-agent/HISTORY.rst +++ b/src/aks-agent/HISTORY.rst @@ -12,6 +12,16 @@ To release a new version, please select a new version number (usually plus 1 to Pending +++++++ +1.0.0b5 ++++++++ +* Bump holmesgpt to 0.15.0 - Enhanced AI debugging experience and bug fixes + * Added TODO list feature to allows holmes to reliably answers questions it wasn't able to answer before due to early-stopping + * Fixed mcp server http connection fails when using socks proxy by adding the missing socks dependency + * Fixed gpt-5 temperature bug by upgrading litellm and dropping non-1 values for temperature + * Improved the installation time by removing unnecessary dependencies and move test dependencies to dev dependency group +* Added Feedback slash command Feature to allow users to provide feedback on their experience with the agent performance +* Disable prometheus toolset loading by default to workaround the libbz2-dev missing issue in Azure CLI python environment. + 1.0.0b4 +++++++ * Fix the --aks-mcp flag to allow true/false values. diff --git a/src/aks-agent/azext_aks_agent/_consts.py b/src/aks-agent/azext_aks_agent/_consts.py index dac35a115fc..57e1a5734b9 100644 --- a/src/aks-agent/azext_aks_agent/_consts.py +++ b/src/aks-agent/azext_aks_agent/_consts.py @@ -3,11 +3,24 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -# aks agent constants +# Constants to customized holmesgpt CONST_AGENT_CONFIG_PATH_DIR_ENV_KEY = "HOLMES_CONFIGPATH_DIR" CONST_AGENT_NAME = "AKS AGENT" CONST_AGENT_NAME_ENV_KEY = "AGENT_NAME" CONST_AGENT_CONFIG_FILE_NAME = "aksAgent.yaml" +CONST_PRIVACY_NOTICE_BANNER_ENV_KEY = "PRIVACY_NOTICE_BANNER" +# Privacy Notice Banner displayed in the format of rich.Console +CONST_PRIVACY_NOTICE_BANNER = ( + "When you send Microsoft this feedback, you agree we may combine this information, which might include other " + "diagnostic data, to help improve Microsoft products and services. Processing of feedback data is governed by " + "the Microsoft Products and Services Data Protection Addendum between your organization and Microsoft, and the " + "feedback you submit is considered Personal Data under that addendum. " + "Privacy Statement: https://go.microsoft.com/fwlink/?LinkId=521839" +) +# Holmesgpt leverages prometheus_api_client for prometheus toolsets and introduces bz2 library. +# Before libbz2-dev is bundled into azure cli python by https://github.com/Azure/azure-cli/pull/32163, +# we ignore loading prometheus toolset to avoid loading error of bz2 module. +CONST_DISABLE_PROMETHEUS_TOOLSET_ENV_KEY = "DISABLE_PROMETHEUS_TOOLSET" # MCP Integration Constants (ported from previous change) CONST_MCP_BINARY_NAME = "aks-mcp" diff --git a/src/aks-agent/azext_aks_agent/_params.py b/src/aks-agent/azext_aks_agent/_params.py index d830ab56d02..5d45610c9fb 100644 --- a/src/aks-agent/azext_aks_agent/_params.py +++ b/src/aks-agent/azext_aks_agent/_params.py @@ -6,12 +6,10 @@ # pylint: disable=too-many-statements,too-many-lines import os.path -from azure.cli.core.api import get_config_dir -from azure.cli.core.commands.parameters import get_three_state_flag - from azext_aks_agent._consts import CONST_AGENT_CONFIG_FILE_NAME - from azext_aks_agent._validators import validate_agent_config_file +from azure.cli.core.api import get_config_dir +from azure.cli.core.commands.parameters import get_three_state_flag def load_arguments(self, _): @@ -37,7 +35,7 @@ def load_arguments(self, _): c.argument( "max_steps", type=int, - default=10, + default=40, required=False, help="Maximum number of steps the LLM can take to investigate the issue.", ) diff --git a/src/aks-agent/azext_aks_agent/agent/agent.py b/src/aks-agent/azext_aks_agent/agent/agent.py index f8ec41002be..50e079de4c7 100644 --- a/src/aks-agent/azext_aks_agent/agent/agent.py +++ b/src/aks-agent/azext_aks_agent/agent/agent.py @@ -11,14 +11,25 @@ CONST_AGENT_CONFIG_PATH_DIR_ENV_KEY, CONST_AGENT_NAME, CONST_AGENT_NAME_ENV_KEY, + CONST_DISABLE_PROMETHEUS_TOOLSET_ENV_KEY, + CONST_PRIVACY_NOTICE_BANNER, + CONST_PRIVACY_NOTICE_BANNER_ENV_KEY, ) from azure.cli.core.api import get_config_dir from azure.cli.core.commands.client_factory import get_subscription_id from knack.util import CLIError +from .error_handler import MCPError from .prompt import AKS_CONTEXT_PROMPT_MCP, AKS_CONTEXT_PROMPT_TRADITIONAL from .telemetry import CLITelemetryClient -from .error_handler import MCPError + + +# NOTE(mainred): environment variables to disable prometheus toolset loading should be set before importing holmes. +def customize_holmesgpt(): + os.environ[CONST_DISABLE_PROMETHEUS_TOOLSET_ENV_KEY] = "true" + os.environ[CONST_AGENT_CONFIG_PATH_DIR_ENV_KEY] = get_config_dir() + os.environ[CONST_AGENT_NAME_ENV_KEY] = CONST_AGENT_NAME + os.environ[CONST_PRIVACY_NOTICE_BANNER_ENV_KEY] = CONST_PRIVACY_NOTICE_BANNER # NOTE(mainred): holmes leverage the log handler RichHandler to provide colorful, readable and well-formatted logs @@ -151,21 +162,19 @@ def aks_agent( :type use_aks_mcp: bool """ - with CLITelemetryClient(): + with CLITelemetryClient() as telemetry: if sys.version_info < (3, 10): raise CLIError( "Please upgrade the python version to 3.10 or above to use aks agent." ) + # customizing holmesgpt should called before importing holmes + customize_holmesgpt() # Initialize variables interactive = not no_interactive echo = not no_echo_request console = init_log() - # Set environment variables for Holmes - os.environ[CONST_AGENT_CONFIG_PATH_DIR_ENV_KEY] = get_config_dir() - os.environ[CONST_AGENT_NAME_ENV_KEY] = CONST_AGENT_NAME - # Detect and read piped input piped_data = None if not sys.stdin.isatty(): @@ -265,7 +274,7 @@ def aks_agent( is_mcp_mode = current_mode == "mcp" if interactive: _run_interactive_mode_sync(ai, cmd, resource_group_name, name, - prompt, console, show_tool_output, is_mcp_mode) + prompt, console, show_tool_output, is_mcp_mode, telemetry) else: _run_noninteractive_mode_sync(ai, config, cmd, resource_group_name, name, prompt, console, echo, show_tool_output, is_mcp_mode) @@ -312,13 +321,15 @@ async def _setup_mcp_mode(mcp_manager, config_file: str, model: str, api_key: st :return: Enhanced Holmes configuration :raises: Exception if MCP setup fails """ + import tempfile from pathlib import Path + import yaml - import tempfile from holmes.config import Config + from .config_generator import ConfigurationGenerator - from .user_feedback import ProgressReporter from .error_handler import AgentErrorHandler + from .user_feedback import ProgressReporter # Ensure binary is available (download if needed) if not mcp_manager.is_binary_available() or not mcp_manager.validate_binary_version(): @@ -602,7 +613,7 @@ def _build_aks_context(cluster_name, resource_group_name, subscription_id, is_mc def _run_interactive_mode_sync(ai, cmd, resource_group_name, name, - prompt, console, show_tool_output, is_mcp_mode): + prompt, console, show_tool_output, is_mcp_mode, telemetry): """ Run interactive mode synchronously - no event loop conflicts. @@ -617,6 +628,7 @@ def _run_interactive_mode_sync(ai, cmd, resource_group_name, name, :param console: Console object for output :param show_tool_output: Whether to show tool output :param is_mcp_mode: Whether running in MCP mode (affects prompt selection) + :param telemetry: CLITelemetryClient instance for tracking events """ from holmes.interactive import run_interactive_loop @@ -633,7 +645,8 @@ def _run_interactive_mode_sync(ai, cmd, resource_group_name, name, ai, console, prompt, None, None, show_tool_output=show_tool_output, system_prompt_additions=aks_context, - check_version=False + check_version=False, + feedback_callback=telemetry.track_agent_feedback if telemetry else None ) @@ -653,8 +666,9 @@ def _run_noninteractive_mode_sync(ai, config, cmd, resource_group_name, name, :param show_tool_output: Whether to show tool output :param is_mcp_mode: Whether running in MCP mode (affects prompt selection) """ - import uuid import socket + import uuid + from holmes.core.prompt import build_initial_ask_messages from holmes.plugins.destinations import DestinationType from holmes.plugins.interfaces import Issue @@ -702,10 +716,12 @@ def _setup_traditional_mode_sync(config_file: str, model: str, api_key: str, :param verbose: Enable verbose output :return: Traditional Holmes configuration """ + import tempfile from pathlib import Path + import yaml - import tempfile from holmes.config import Config + from .config_generator import ConfigurationGenerator # Load base config diff --git a/src/aks-agent/azext_aks_agent/agent/telemetry.py b/src/aks-agent/azext_aks_agent/agent/telemetry.py index 581eca87ca1..08da8790fed 100644 --- a/src/aks-agent/azext_aks_agent/agent/telemetry.py +++ b/src/aks-agent/azext_aks_agent/agent/telemetry.py @@ -4,13 +4,17 @@ # -------------------------------------------------------------------------------------------- import datetime +import json import logging import os import platform from applicationinsights import TelemetryClient -from azure.cli.core.telemetry import (_get_azure_subscription_id, - _get_hash_mac_address, _get_user_agent) +from azure.cli.core.telemetry import ( + _get_azure_subscription_id, + _get_hash_mac_address, + _get_user_agent, +) DEFAULT_INSTRUMENTATION_KEY = "c301e561-daea-42d9-b9d1-65fca4166704" APPLICATIONINSIGHTS_INSTRUMENTATION_KEY_ENV = "APPLICATIONINSIGHTS_INSTRUMENTATION_KEY" @@ -75,3 +79,21 @@ def _get_application_insights_instrumentation_key(self) -> str: return os.getenv( APPLICATIONINSIGHTS_INSTRUMENTATION_KEY_ENV, DEFAULT_INSTRUMENTATION_KEY ) + + def track_agent_feedback(self, feedback): + # NOTE: We should try to avoid importing holmesgpt at the top level to prevent dependency issues + from holmes.core.feedback import Feedback, FeedbackMetadata + + # Type hint validation for development purposes + if not isinstance(feedback, Feedback): + raise TypeError(f"Expected Feedback object, got {type(feedback)}") + + # Before privacy team's approval for other user data, we keep only direct user feedback, and model info. + feedback_filtered = Feedback() + feedback_filtered.user_feedback = feedback.user_feedback + feedback_metadata = FeedbackMetadata() + feedback_metadata.model = feedback.metadata.model + feedback_filtered.metadata = feedback_metadata + self.track("AgentCLIFeedback", properties={"feedback": json.dumps(feedback_filtered.to_dict())}) + # Flush the telemetry data immediately to avoid too much data being sent at once + self.flush() diff --git a/src/aks-agent/azext_aks_agent/tests/latest/test_aks_agent_mcp_integration.py b/src/aks-agent/azext_aks_agent/tests/latest/test_aks_agent_mcp_integration.py index 72a9b68c53e..d62ab2911b1 100644 --- a/src/aks-agent/azext_aks_agent/tests/latest/test_aks_agent_mcp_integration.py +++ b/src/aks-agent/azext_aks_agent/tests/latest/test_aks_agent_mcp_integration.py @@ -9,6 +9,7 @@ import asyncio from unittest.mock import AsyncMock, Mock, patch + import pytest @@ -27,13 +28,13 @@ def setup_method(self): def test_initialize_mcp_manager_success(self): """Test successful MCP manager initialization.""" from azext_aks_agent.agent.agent import _initialize_mcp_manager - + with patch('azext_aks_agent.agent.mcp_manager.MCPManager') as mock_mcp_class: mock_manager = Mock() mock_mcp_class.return_value = mock_manager - + result = _initialize_mcp_manager(verbose=True) - + assert result == mock_manager mock_mcp_class.assert_called_once_with(verbose=True) @@ -41,41 +42,42 @@ def test_initialize_mcp_manager_import_error(self): """Test MCP manager initialization with import error.""" from azext_aks_agent.agent.agent import _initialize_mcp_manager from azext_aks_agent.agent.error_handler import MCPError - + with patch('azext_aks_agent.agent.mcp_manager.MCPManager', side_effect=ImportError("Module not found")): with pytest.raises(MCPError) as exc_info: _initialize_mcp_manager() - + assert "MCP manager initialization failed" in str(exc_info.value) assert exc_info.value.error_code == "MCP_IMPORT" assert "Ensure all required dependencies are installed" in exc_info.value.suggestions[0] + @pytest.mark.skip(reason="The async test is currently not supported in test pipeline.") @pytest.mark.asyncio async def test_setup_mcp_mode_basic_workflow(self): """Test basic MCP mode setup workflow without complex mocking.""" from azext_aks_agent.agent.agent import _setup_mcp_mode from azext_aks_agent.agent.binary_manager import BinaryStatus - + # Create a simple mock manager mock_manager = Mock() mock_manager.is_binary_available.return_value = True mock_manager.validate_binary_version.return_value = True mock_manager.start_server = AsyncMock(return_value=True) mock_manager.get_server_url.return_value = "http://localhost:8003/sse" - + # Mock binary status mock_binary_status = BinaryStatus(available=True, version_valid=True) mock_manager.binary_manager.ensure_binary = AsyncMock(return_value=mock_binary_status) - + # Test with a non-existent config file (will use empty config) with patch('pathlib.Path.exists', return_value=False), \ - patch('tempfile.NamedTemporaryFile') as mock_temp_file, \ - patch('yaml.dump') as mock_yaml_dump, \ - patch('os.unlink'): - + patch('tempfile.NamedTemporaryFile') as mock_temp_file, \ + patch('yaml.dump') as mock_yaml_dump, \ + patch('os.unlink'): + # Mock the temporary file context manager mock_temp_file.return_value.__enter__.return_value.name = "/tmp/test_config.yaml" - + # This should fail because we haven't mocked Holmes Config.load_from_file, # but that's expected - we're just testing the workflow doesn't crash try: @@ -86,113 +88,118 @@ async def test_setup_mcp_mode_basic_workflow(self): except Exception as e: # Expected to fail at Config.load_from_file assert "Config" in str(e) or "load_from_file" in str(e) or "ImportError" in str(e) - + # Verify the manager methods were called correctly mock_manager.start_server.assert_called_once() assert mock_manager.get_server_url.called - + # Check the content of the configuration that was passed to yaml.dump if mock_yaml_dump.call_count > 0: config_data = mock_yaml_dump.call_args[0][0] - + # Verify MCP server configuration is present assert "mcp_servers" in config_data assert "aks-mcp" in config_data["mcp_servers"] assert config_data["mcp_servers"]["aks-mcp"]["url"] == "http://localhost:8003/sse" - + # Verify conflicting toolsets are disabled assert "toolsets" in config_data toolsets = config_data["toolsets"] assert toolsets["aks/core"]["enabled"] is False assert toolsets["kubernetes/core"]["enabled"] is False - + + @pytest.mark.skip(reason="The async test is currently not supported in test pipeline.") @pytest.mark.asyncio async def test_setup_mcp_mode_binary_not_available(self): """Test MCP mode setup when binary is not available and download fails.""" from azext_aks_agent.agent.agent import _setup_mcp_mode from azext_aks_agent.agent.binary_manager import BinaryStatus from azext_aks_agent.agent.error_handler import BinaryError - + # Setup mocks mock_manager = Mock() mock_manager.is_binary_available.return_value = False mock_manager.validate_binary_version.return_value = False - + # Mock failed binary download mock_binary_status = BinaryStatus(available=False, error_message="Download failed") mock_manager.binary_manager.ensure_binary = AsyncMock(return_value=mock_binary_status) - + # Test the function with pytest.raises(BinaryError) as exc_info: await _setup_mcp_mode( mock_manager, self.test_config_file, self.test_model, self.test_api_key, self.test_max_steps, verbose=True ) - + assert "Binary setup failed" in str(exc_info.value) assert exc_info.value.error_code == "BINARY_SETUP" + @pytest.mark.skip(reason="The async test is currently not supported in test pipeline.") @pytest.mark.asyncio async def test_setup_mcp_mode_server_start_failure(self): """Test MCP mode setup when server fails to start.""" from azext_aks_agent.agent.agent import _setup_mcp_mode from azext_aks_agent.agent.binary_manager import BinaryStatus from azext_aks_agent.agent.error_handler import ServerError - + # Setup mocks mock_manager = Mock() mock_manager.is_binary_available.return_value = True mock_manager.validate_binary_version.return_value = True mock_manager.start_server = AsyncMock(return_value=False) - + mock_binary_status = BinaryStatus(available=True, version_valid=True) mock_manager.binary_manager.ensure_binary = AsyncMock(return_value=mock_binary_status) - + # Test the function with pytest.raises(ServerError) as exc_info: await _setup_mcp_mode( mock_manager, self.test_config_file, self.test_model, self.test_api_key, self.test_max_steps, verbose=True ) - + assert "Server startup failed" in str(exc_info.value) assert exc_info.value.error_code == "SERVER_STARTUP" def test_error_handler_functionality(self): """Test the enhanced error handling system.""" from azext_aks_agent.agent.error_handler import ( - AgentErrorHandler, MCPError, BinaryError, ServerError + AgentErrorHandler, + BinaryError, + MCPError, + ServerError, ) - + # Test MCP setup error handling original_error = ConnectionError("Network connection failed") mcp_error = AgentErrorHandler.handle_mcp_setup_error(original_error, "initialization") - + assert isinstance(mcp_error, MCPError) assert "MCP setup failed during initialization" in str(mcp_error) assert mcp_error.error_code == "MCP_SETUP" assert "Check your internet connection" in mcp_error.suggestions - + # Test binary error handling binary_error = AgentErrorHandler.handle_binary_error( Exception("Download timeout"), "download" ) - + assert isinstance(binary_error, BinaryError) assert "Binary download failed" in str(binary_error) assert binary_error.error_code == "BINARY_DOWNLOAD" assert "Verify you have internet connectivity" in binary_error.suggestions - + # Test server error handling server_error = AgentErrorHandler.handle_server_error( Exception("Port in use"), "startup" ) - + assert isinstance(server_error, ServerError) assert "MCP server startup failed" in str(server_error) assert server_error.error_code == "SERVER_STARTUP" assert "Check if the MCP binary is available and executable" in server_error.suggestions - + # Test error message formatting formatted_message = AgentErrorHandler.format_error_message(mcp_error) assert "AKS Agent Error (MCP_SETUP)" in formatted_message @@ -202,9 +209,10 @@ def test_error_handler_functionality(self): def test_setup_traditional_mode_config_loading(self): """Test traditional mode setup with actual config loading.""" import tempfile + import yaml from azext_aks_agent.agent.config_generator import ConfigurationGenerator - + # Create a temporary config file test_config = { "existing": "config", @@ -212,40 +220,40 @@ def test_setup_traditional_mode_config_loading(self): "custom/toolset": {"enabled": True} } } - + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: yaml.dump(test_config, f) config_file_path = f.name - + try: # Test loading and processing config as dictionary from pathlib import Path - + expanded_config_file = Path(config_file_path) base_config_dict = {} - + if expanded_config_file.exists(): with open(expanded_config_file, 'r') as f: base_config_dict = yaml.safe_load(f) or {} - + # Use ConfigurationGenerator to create traditional config traditional_config_dict = ConfigurationGenerator.generate_traditional_config(base_config_dict) - + # Verify the configuration was processed correctly assert "toolsets" in traditional_config_dict assert "existing" in traditional_config_dict assert traditional_config_dict["existing"] == "config" - + # Verify traditional toolsets are enabled toolsets = traditional_config_dict["toolsets"] assert toolsets["aks/core"]["enabled"] is True assert toolsets["kubernetes/core"]["enabled"] is True assert toolsets["kubernetes/live-metrics"]["enabled"] is True assert toolsets["custom/toolset"]["enabled"] is True - + # Verify no MCP servers are configured assert "mcp_servers" not in traditional_config_dict - + finally: Path(config_file_path).unlink() # Clean up temp file @@ -253,10 +261,10 @@ def test_setup_traditional_mode_config_loading(self): def test_aks_agent_calls_sync_implementation(self, mock_stdin): """Test that aks_agent works with new synchronous implementation.""" from azext_aks_agent.agent.agent import aks_agent - + # Mock stdin to avoid pytest capture issues mock_stdin.isatty.return_value = True # No piped input - + # Call the function with use_aks_mcp=False to avoid MCP setup try: aks_agent( @@ -284,13 +292,13 @@ def test_python_version_check(self, mock_stdin): """Test that agent checks Python version requirement.""" from azext_aks_agent.agent.agent import aks_agent from knack.util import CLIError - - # Mock stdin to avoid pytest capture issues + + # Mock stdin to avoid pytest capture issues mock_stdin.isatty.return_value = True # No piped input - + with patch('azext_aks_agent.agent.agent.sys') as mock_sys: mock_sys.version_info = (3, 9, 0) # Below required version - + with pytest.raises(CLIError) as exc_info: aks_agent( self.mock_cmd, @@ -307,5 +315,5 @@ def test_python_version_check(self, mock_stdin): False, use_aks_mcp=False, ) - + assert "upgrade the python version to 3.10" in str(exc_info.value) diff --git a/src/aks-agent/azext_aks_agent/tests/latest/test_aks_agent_status.py b/src/aks-agent/azext_aks_agent/tests/latest/test_aks_agent_status.py index 995e0efdd4d..5c3c76ff6f6 100644 --- a/src/aks-agent/azext_aks_agent/tests/latest/test_aks_agent_status.py +++ b/src/aks-agent/azext_aks_agent/tests/latest/test_aks_agent_status.py @@ -7,125 +7,133 @@ Unit tests for agent status collection functionality. """ -import os import json +import os import tempfile -import pytest -from unittest.mock import Mock, patch from datetime import datetime, timedelta +from unittest.mock import Mock, patch +import pytest from azext_aks_agent.agent.status import AgentStatusManager -from azext_aks_agent.agent.status_models import AgentStatus, BinaryStatus, ServerStatus, ConfigStatus +from azext_aks_agent.agent.status_models import ( + AgentStatus, + BinaryStatus, + ConfigStatus, + ServerStatus, +) class TestAgentStatusManager: """Test cases for AgentStatusManager.""" - + def setup_method(self): """Set up test fixtures.""" self.temp_dir = tempfile.mkdtemp() self.status_manager = AgentStatusManager(config_dir=self.temp_dir) - + def teardown_method(self): """Clean up test fixtures.""" import shutil shutil.rmtree(self.temp_dir, ignore_errors=True) - + def test_status_manager_init_with_default_config_dir(self): """Test initialization with default config directory.""" with patch('azext_aks_agent.agent.status.get_config_dir') as mock_get_config_dir: mock_get_config_dir.return_value = '/mock/config/dir' - + manager = AgentStatusManager() - + assert manager.config_dir == '/mock/config/dir' mock_get_config_dir.assert_called_once() - + def test_status_manager_init_with_custom_config_dir(self): """Test initialization with custom config directory.""" custom_dir = '/custom/config/dir' manager = AgentStatusManager(config_dir=custom_dir) - + assert manager.config_dir == custom_dir - + + @pytest.mark.skip(reason="The async test is currently not supported in test pipeline.") @pytest.mark.asyncio @patch('azext_aks_agent.agent.status.psutil') async def test_get_status_success(self, mock_psutil): """Test successful status collection.""" # Mock binary manager with patch.object(self.status_manager.binary_manager, 'get_binary_path') as mock_path, \ - patch.object(self.status_manager.binary_manager, 'get_binary_version') as mock_version, \ - patch.object(self.status_manager.binary_manager, 'validate_version') as mock_validate, \ - patch('os.path.exists') as mock_exists: - + patch.object(self.status_manager.binary_manager, 'get_binary_version') as mock_version, \ + patch.object(self.status_manager.binary_manager, 'validate_version') as mock_validate, \ + patch('os.path.exists') as mock_exists: + mock_path.return_value = '/mock/binary/path' mock_version.return_value = '1.0.0' mock_validate.return_value = True mock_exists.return_value = True - + # Mock process info mock_process = Mock() mock_process.create_time.return_value = datetime.now().timestamp() - 3600 # 1 hour ago mock_psutil.Process.return_value = mock_process - + # Mock file stats with patch('os.stat') as mock_stat, \ - patch('os.path.getmtime') as mock_getmtime: - + patch('os.path.getmtime') as mock_getmtime: + mock_stat.return_value.st_size = 1024 mock_stat.return_value.st_mtime = datetime.now().timestamp() mock_getmtime.return_value = datetime.now().timestamp() - + status = await self.status_manager.get_status() - + assert isinstance(status, AgentStatus) assert isinstance(status.mcp_binary, BinaryStatus) assert isinstance(status.server, ServerStatus) assert isinstance(status.config, ConfigStatus) - + + @pytest.mark.skip(reason="The async test is currently not supported in test pipeline.") @pytest.mark.asyncio async def test_get_status_with_error(self): """Test status collection with error.""" # Mock determine_current_mode to raise exception with patch.object(self.status_manager, '_determine_current_mode', side_effect=Exception("Test error")): - + status = await self.status_manager.get_status() - + assert status.mode == "error" assert "Status collection failed" in status.error_message - + def test_get_mcp_binary_status_available(self): """Test MCP binary status when binary is available.""" with patch.object(self.status_manager.binary_manager, 'get_binary_path') as mock_path, \ - patch.object(self.status_manager.binary_manager, 'get_binary_version') as mock_version, \ - patch.object(self.status_manager.binary_manager, 'validate_version') as mock_validate, \ - patch('os.path.exists') as mock_exists, \ - patch('azext_aks_agent.agent.status_models.BinaryStatus.from_file_path') as mock_from_path: - + patch.object(self.status_manager.binary_manager, 'get_binary_version') as mock_version, \ + patch.object(self.status_manager.binary_manager, 'validate_version') as mock_validate, \ + patch('os.path.exists') as mock_exists, \ + patch('azext_aks_agent.agent.status_models.BinaryStatus.from_file_path') as mock_from_path: + mock_path.return_value = '/mock/binary/path' mock_version.return_value = '1.0.0' mock_validate.return_value = True mock_exists.return_value = True - + expected_status = BinaryStatus(available=True, version='1.0.0', version_valid=True) mock_from_path.return_value = expected_status - + result = self.status_manager._get_mcp_binary_status() - + assert result == expected_status mock_from_path.assert_called_once_with('/mock/binary/path', version='1.0.0', version_valid=True) - + def test_get_mcp_binary_status_not_available(self): """Test MCP binary status when binary is not available.""" with patch.object(self.status_manager.binary_manager, 'get_binary_path', return_value='/mock/bin'), \ - patch('os.path.exists', return_value=False): - + patch('os.path.exists', return_value=False): + result = self.status_manager._get_mcp_binary_status() - + assert not result.available assert result.path == '/mock/bin' assert result.error_message == 'Binary not found' - + + @pytest.mark.skip(reason="The async test is currently not supported in test pipeline.") @pytest.mark.asyncio @patch('azext_aks_agent.agent.status.MCPManager') @patch('azext_aks_agent.agent.status.psutil') @@ -139,24 +147,25 @@ async def test_get_server_status_running_healthy(self, mock_psutil, mock_mcp_man mock_manager.get_server_port.return_value = 8003 mock_manager.server_process = Mock() mock_manager.server_process.pid = 12345 - + mock_mcp_manager_class.return_value = mock_manager - + # Mock process info mock_process = Mock() start_time = datetime.now() - timedelta(hours=1) mock_process.create_time.return_value = start_time.timestamp() mock_psutil.Process.return_value = mock_process - + result = await self.status_manager._get_server_status() - + assert result.running assert result.healthy assert result.url == 'http://localhost:8003/sse' assert result.port == 8003 assert result.pid == 12345 assert result.uptime is not None - + + @pytest.mark.skip(reason="The async test is currently not supported in test pipeline.") @pytest.mark.asyncio @patch('azext_aks_agent.agent.status.MCPManager') async def test_get_server_status_not_running(self, mock_mcp_manager_class): @@ -165,34 +174,35 @@ async def test_get_server_status_not_running(self, mock_mcp_manager_class): mock_manager = Mock() mock_manager.is_server_running.return_value = False mock_mcp_manager_class.return_value = mock_manager - + result = await self.status_manager._get_server_status() - + assert not result.running assert not result.healthy assert result.url is None assert result.port is None assert result.pid is None - + + @pytest.mark.skip(reason="The async test is currently not supported in test pipeline.") @pytest.mark.asyncio @patch('azext_aks_agent.agent.status.MCPManager') async def test_get_server_status_with_exception(self, mock_mcp_manager_class): """Test server status collection with exception.""" mock_mcp_manager_class.side_effect = Exception("Test error") - + result = await self.status_manager._get_server_status() - + assert not result.running assert not result.healthy assert "Server status check failed" in result.error_message - + def test_get_configuration_status_mcp_mode(self): """Test configuration status in MCP mode.""" # Create mock state file state_file_path = os.path.join(self.temp_dir, "aks_agent_mode_state") with open(state_file_path, 'w') as f: json.dump({"last_mode": "mcp"}, f) - + # Create mock config file config_file_path = os.path.join(self.temp_dir, "aksAgent.yaml") config_data = { @@ -206,189 +216,189 @@ def test_get_configuration_status_mcp_mode(self): } with open(config_file_path, 'w') as f: json.dump(config_data, f) - + with patch('azext_aks_agent.agent.status.ConfigurationGenerator.validate_mcp_config') as mock_validate: mock_validate.return_value = True - + result = self.status_manager._get_configuration_status() - + assert result.mode == "mcp" assert result.config_valid assert len(result.mcp_servers) == 1 assert "aks-mcp" in result.mcp_servers - + def test_get_configuration_status_traditional_mode(self): """Test configuration status in traditional mode.""" # Create mock state file state_file_path = os.path.join(self.temp_dir, "aks_agent_mode_state") with open(state_file_path, 'w') as f: json.dump({"last_mode": "traditional"}, f) - + with patch('azext_aks_agent.agent.status.ConfigurationGenerator.validate_traditional_config') as mock_validate: mock_validate.return_value = True - + result = self.status_manager._get_configuration_status() - + assert result.mode == "traditional" assert result.config_valid - + def test_get_configuration_status_with_exception(self): """Test configuration status collection with exception.""" with patch('os.path.exists', side_effect=Exception("Test error")): - + result = self.status_manager._get_configuration_status() - + assert result.mode == "unknown" assert not result.config_valid assert "Configuration status check failed" in result.error_message - + def test_determine_current_mode_mcp(self): """Test mode determination for MCP mode.""" config_status = ConfigStatus(mode="mcp") binary_status = BinaryStatus(available=True, version_valid=True) server_status = ServerStatus(running=True) - + result = self.status_manager._determine_current_mode(config_status, binary_status, server_status) - + assert result == "mcp" - + def test_determine_current_mode_traditional(self): """Test mode determination for traditional mode.""" config_status = ConfigStatus(mode="traditional") binary_status = BinaryStatus(available=False) server_status = ServerStatus(running=False) - + result = self.status_manager._determine_current_mode(config_status, binary_status, server_status) - + assert result == "traditional" - + def test_determine_current_mode_inferred_mcp(self): """Test mode determination inferred as MCP from component status.""" config_status = ConfigStatus(mode="unknown") binary_status = BinaryStatus(available=True, version_valid=True) server_status = ServerStatus(running=True) - + result = self.status_manager._determine_current_mode(config_status, binary_status, server_status) - + assert result == "mcp" - + def test_determine_current_mode_mcp_available(self): """Test mode determination for MCP available but server not running.""" config_status = ConfigStatus(mode="unknown") binary_status = BinaryStatus(available=True) server_status = ServerStatus(running=False) - + result = self.status_manager._determine_current_mode(config_status, binary_status, server_status) - + assert result == "mcp_available" - + def test_get_last_mode_from_file(self): """Test getting last mode from state file.""" state_file_path = os.path.join(self.temp_dir, "aks_agent_mode_state") with open(state_file_path, 'w') as f: json.dump({"last_mode": "mcp"}, f) - + result = self.status_manager._get_last_mode() - + assert result == "mcp" - + def test_get_last_mode_no_file(self): """Test getting last mode when file doesn't exist.""" result = self.status_manager._get_last_mode() - + assert result == "unknown" - + def test_get_last_mode_invalid_json(self): """Test getting last mode with invalid JSON in file.""" state_file_path = os.path.join(self.temp_dir, "aks_agent_mode_state") with open(state_file_path, 'w') as f: f.write("invalid json") - + result = self.status_manager._get_last_mode() - + assert result == "unknown" - + def test_get_last_mode_change_time(self): """Test getting last mode change time.""" state_file_path = os.path.join(self.temp_dir, "aks_agent_mode_state") with open(state_file_path, 'w') as f: json.dump({"last_mode": "mcp"}, f) - + result = self.status_manager._get_last_mode_change_time() - + assert result is not None assert isinstance(result, datetime) - + def test_get_last_mode_change_time_no_file(self): """Test getting last mode change time when file doesn't exist.""" result = self.status_manager._get_last_mode_change_time() - + assert result is None - + def test_get_last_used_timestamp(self): """Test getting last used timestamp.""" # Create some files with different timestamps config_file_path = os.path.join(self.temp_dir, "aksAgent.yaml") state_file_path = os.path.join(self.temp_dir, "aks_agent_mode_state") - + with open(config_file_path, 'w') as f: f.write("{}") - + with open(state_file_path, 'w') as f: f.write("{}") - + result = self.status_manager._get_last_used_timestamp() - + assert result is not None assert isinstance(result, datetime) - + def test_get_last_used_timestamp_no_files(self): """Test getting last used timestamp when no files exist.""" result = self.status_manager._get_last_used_timestamp() - + assert result is None - + def test_load_config_file_json(self): """Test loading JSON configuration file.""" config_file_path = os.path.join(self.temp_dir, "test_config.json") config_data = {"test": "data"} - + with open(config_file_path, 'w') as f: json.dump(config_data, f) - + result = self.status_manager._load_config_file(config_file_path) - + assert result == config_data - + def test_load_config_file_yaml(self): """Test loading YAML configuration file.""" config_file_path = os.path.join(self.temp_dir, "test_config.yaml") - + with open(config_file_path, 'w') as f: f.write("test: data\n") - + with patch('yaml.safe_load') as mock_yaml: mock_yaml.return_value = {"test": "data"} - + result = self.status_manager._load_config_file(config_file_path) - + assert result == {"test": "data"} - + def test_load_config_file_nonexistent(self): """Test loading nonexistent configuration file.""" result = self.status_manager._load_config_file("/nonexistent/file.json") - + assert result is None - + def test_load_config_file_invalid_json_falls_back_to_yaml(self): """Test loading invalid JSON configuration file falls back to YAML then fails gracefully.""" config_file_path = os.path.join(self.temp_dir, "invalid.json") - + with open(config_file_path, 'w') as f: f.write("invalid json content") - + # Mock yaml to also fail, simulating no yaml library or invalid yaml with patch('yaml.safe_load', side_effect=Exception("YAML parse error")): result = self.status_manager._load_config_file(config_file_path) - + assert result is None diff --git a/src/aks-agent/setup.py b/src/aks-agent/setup.py index 1206b6f475f..ee2dbf93b6c 100644 --- a/src/aks-agent/setup.py +++ b/src/aks-agent/setup.py @@ -9,7 +9,7 @@ from setuptools import find_packages, setup -VERSION = "1.0.0b4" +VERSION = "1.0.0b5" CLASSIFIERS = [ "Development Status :: 4 - Beta", @@ -24,8 +24,7 @@ ] DEPENDENCIES = [ - "holmesgpt==0.12.6; python_version >= '3.10'", - "pytest-asyncio>=1.1.0", + "holmesgpt==0.15.0; python_version >= '3.10'", ] with open1("README.rst", "r", encoding="utf-8") as f: