diff --git a/libraries/microsoft-agents-a365-runtime/README.md b/libraries/microsoft-agents-a365-runtime/README.md index 4c06570..9cf2934 100644 --- a/libraries/microsoft-agents-a365-runtime/README.md +++ b/libraries/microsoft-agents-a365-runtime/README.md @@ -13,7 +13,58 @@ pip install microsoft-agents-a365-runtime ## Usage -For usage examples and detailed documentation, see the [Microsoft Agent 365 Developer documentation](https://learn.microsoft.com/microsoft-agent-365/developer/?tabs=python) on Microsoft Learn. +### Agent Settings Service + +The Agent Settings Service provides methods to manage agent settings templates and instance-specific settings: + +```python +import asyncio +from microsoft_agents_a365.runtime import ( + AgentSettingsService, + AgentSettingTemplate, + AgentSettings, + PowerPlatformApiDiscovery, +) + +# Initialize the service +api_discovery = PowerPlatformApiDiscovery("prod") +tenant_id = "your-tenant-id" +service = AgentSettingsService(api_discovery, tenant_id) + +async def main(): + access_token = "your-access-token" + + # Get agent setting template by agent type + template = await service.get_agent_setting_template( + "my-agent-type", + access_token + ) + + # Set agent setting template + new_template = AgentSettingTemplate( + agent_type="my-agent-type", + settings={"key1": "value1", "key2": "value2"} + ) + await service.set_agent_setting_template(new_template, access_token) + + # Get agent settings by instance + settings = await service.get_agent_settings( + "agent-instance-id", + access_token + ) + + # Set agent settings by instance + new_settings = AgentSettings( + agent_instance_id="agent-instance-id", + agent_type="my-agent-type", + settings={"instanceKey": "instanceValue"} + ) + await service.set_agent_settings(new_settings, access_token) + +asyncio.run(main()) +``` + +For more usage examples and detailed documentation, see the [Microsoft Agent 365 Developer documentation](https://learn.microsoft.com/microsoft-agent-365/developer/?tabs=python) on Microsoft Learn. ## Support diff --git a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py index 24a54ef..7cf1397 100644 --- a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py +++ b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/__init__.py @@ -1,10 +1,18 @@ # Copyright (c) Microsoft. All rights reserved. +from .agent_settings_service import ( + AgentSettings, + AgentSettingsService, + AgentSettingTemplate, +) from .environment_utils import get_observability_authentication_scope from .power_platform_api_discovery import ClusterCategory, PowerPlatformApiDiscovery from .utility import Utility __all__ = [ + "AgentSettings", + "AgentSettingsService", + "AgentSettingTemplate", "get_observability_authentication_scope", "PowerPlatformApiDiscovery", "ClusterCategory", diff --git a/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/agent_settings_service.py b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/agent_settings_service.py new file mode 100644 index 0000000..fcf5f9f --- /dev/null +++ b/libraries/microsoft-agents-a365-runtime/microsoft_agents_a365/runtime/agent_settings_service.py @@ -0,0 +1,271 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Service for managing agent settings templates and instance-specific settings.""" + +from dataclasses import dataclass +from typing import Any +from urllib.parse import quote + +import httpx + +from .power_platform_api_discovery import PowerPlatformApiDiscovery + + +@dataclass +class AgentSettingTemplate: + """Represents an agent setting template. + + Attributes: + agent_type: The agent type identifier. + settings: The settings template as a key-value dictionary. + metadata: Optional metadata about the template. + """ + + agent_type: str + settings: dict[str, Any] + metadata: dict[str, Any] | None = None + + +@dataclass +class AgentSettings: + """Represents agent settings for a specific instance. + + Attributes: + agent_instance_id: The agent instance identifier. + agent_type: The agent type identifier. + settings: The settings as a key-value dictionary. + metadata: Optional metadata about the settings. + """ + + agent_instance_id: str + agent_type: str + settings: dict[str, Any] + metadata: dict[str, Any] | None = None + + +class AgentSettingsService: + """Service for managing agent settings templates and instance-specific settings.""" + + def __init__(self, api_discovery: PowerPlatformApiDiscovery, tenant_id: str) -> None: + """Creates a new instance of AgentSettingsService. + + Args: + api_discovery: The Power Platform API discovery service. + tenant_id: The tenant identifier. + """ + self.api_discovery = api_discovery + self.tenant_id = tenant_id + + def _get_base_endpoint(self) -> str: + """Gets the base endpoint for agent settings API. + + Returns: + The base endpoint URL. + """ + tenant_endpoint = self.api_discovery.get_tenant_endpoint(self.tenant_id) + return f"https://{tenant_endpoint}/agents/v1.0" + + def get_agent_setting_template_endpoint(self, agent_type: str) -> str: + """Gets the endpoint for agent setting templates. + + Args: + agent_type: The agent type identifier. + + Returns: + The endpoint URL for the agent type template. + """ + return f"{self._get_base_endpoint()}/settings/templates/{quote(agent_type, safe='')}" + + def get_agent_settings_endpoint(self, agent_instance_id: str) -> str: + """Gets the endpoint for agent instance settings. + + Args: + agent_instance_id: The agent instance identifier. + + Returns: + The endpoint URL for the agent instance settings. + """ + return f"{self._get_base_endpoint()}/settings/instances/{quote(agent_instance_id, safe='')}" + + async def get_agent_setting_template( + self, agent_type: str, access_token: str + ) -> AgentSettingTemplate: + """Retrieves an agent setting template by agent type. + + Args: + agent_type: The agent type identifier. + access_token: The access token for authentication. + + Returns: + The agent setting template. + + Raises: + httpx.HTTPStatusError: If the API request fails. + """ + endpoint = self.get_agent_setting_template_endpoint(agent_type) + + async with httpx.AsyncClient() as client: + response = await client.get( + endpoint, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + ) + + if not response.is_success: + raise httpx.HTTPStatusError( + f"Failed to get agent setting template for type '{agent_type}': " + f"{response.status_code} {response.reason_phrase}", + request=response.request, + response=response, + ) + + data = response.json() + return AgentSettingTemplate( + agent_type=data["agentType"], + settings=data["settings"], + metadata=data.get("metadata"), + ) + + async def set_agent_setting_template( + self, template: AgentSettingTemplate, access_token: str + ) -> AgentSettingTemplate: + """Sets an agent setting template for a specific agent type. + + Args: + template: The agent setting template to set. + access_token: The access token for authentication. + + Returns: + The updated agent setting template. + + Raises: + httpx.HTTPStatusError: If the API request fails. + """ + endpoint = self.get_agent_setting_template_endpoint(template.agent_type) + + payload = { + "agentType": template.agent_type, + "settings": template.settings, + } + if template.metadata is not None: + payload["metadata"] = template.metadata + + async with httpx.AsyncClient() as client: + response = await client.put( + endpoint, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + json=payload, + ) + + if not response.is_success: + raise httpx.HTTPStatusError( + f"Failed to set agent setting template for type '{template.agent_type}': " + f"{response.status_code} {response.reason_phrase}", + request=response.request, + response=response, + ) + + data = response.json() + return AgentSettingTemplate( + agent_type=data["agentType"], + settings=data["settings"], + metadata=data.get("metadata"), + ) + + async def get_agent_settings( + self, agent_instance_id: str, access_token: str + ) -> AgentSettings: + """Retrieves agent settings for a specific agent instance. + + Args: + agent_instance_id: The agent instance identifier. + access_token: The access token for authentication. + + Returns: + The agent settings. + + Raises: + httpx.HTTPStatusError: If the API request fails. + """ + endpoint = self.get_agent_settings_endpoint(agent_instance_id) + + async with httpx.AsyncClient() as client: + response = await client.get( + endpoint, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + ) + + if not response.is_success: + raise httpx.HTTPStatusError( + f"Failed to get agent settings for instance '{agent_instance_id}': " + f"{response.status_code} {response.reason_phrase}", + request=response.request, + response=response, + ) + + data = response.json() + return AgentSettings( + agent_instance_id=data["agentInstanceId"], + agent_type=data["agentType"], + settings=data["settings"], + metadata=data.get("metadata"), + ) + + async def set_agent_settings( + self, settings: AgentSettings, access_token: str + ) -> AgentSettings: + """Sets agent settings for a specific agent instance. + + Args: + settings: The agent settings to set. + access_token: The access token for authentication. + + Returns: + The updated agent settings. + + Raises: + httpx.HTTPStatusError: If the API request fails. + """ + endpoint = self.get_agent_settings_endpoint(settings.agent_instance_id) + + payload = { + "agentInstanceId": settings.agent_instance_id, + "agentType": settings.agent_type, + "settings": settings.settings, + } + if settings.metadata is not None: + payload["metadata"] = settings.metadata + + async with httpx.AsyncClient() as client: + response = await client.put( + endpoint, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + json=payload, + ) + + if not response.is_success: + raise httpx.HTTPStatusError( + f"Failed to set agent settings for instance '{settings.agent_instance_id}': " + f"{response.status_code} {response.reason_phrase}", + request=response.request, + response=response, + ) + + data = response.json() + return AgentSettings( + agent_instance_id=data["agentInstanceId"], + agent_type=data["agentType"], + settings=data["settings"], + metadata=data.get("metadata"), + ) diff --git a/libraries/microsoft-agents-a365-runtime/pyproject.toml b/libraries/microsoft-agents-a365-runtime/pyproject.toml index c994444..bc0a1b1 100644 --- a/libraries/microsoft-agents-a365-runtime/pyproject.toml +++ b/libraries/microsoft-agents-a365-runtime/pyproject.toml @@ -26,6 +26,7 @@ license = {text = "MIT"} keywords = ["observability", "telemetry", "tracing", "opentelemetry", "monitoring", "ai", "agents"] dependencies = [ "PyJWT >= 2.8.0", + "httpx >= 0.27.0", ] [project.urls] diff --git a/tests/runtime/test_agent_settings_service.py b/tests/runtime/test_agent_settings_service.py new file mode 100644 index 0000000..2e00d36 --- /dev/null +++ b/tests/runtime/test_agent_settings_service.py @@ -0,0 +1,342 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Unit tests for AgentSettingsService class.""" + +from unittest.mock import AsyncMock, Mock, patch + +import httpx +import pytest +from microsoft_agents_a365.runtime.agent_settings_service import ( + AgentSettings, + AgentSettingsService, + AgentSettingTemplate, +) +from microsoft_agents_a365.runtime.power_platform_api_discovery import ( + PowerPlatformApiDiscovery, +) + + +@pytest.fixture +def test_tenant_id(): + return "e3064512-cc6d-4703-be71-a2ecaecaa98a" + + +@pytest.fixture +def test_access_token(): + return "test-access-token-123" + + +@pytest.fixture +def test_agent_type(): + return "test-agent-type" + + +@pytest.fixture +def test_agent_instance_id(): + return "test-agent-instance-123" + + +@pytest.fixture +def api_discovery(): + return PowerPlatformApiDiscovery("prod") + + +@pytest.fixture +def service(api_discovery, test_tenant_id): + return AgentSettingsService(api_discovery, test_tenant_id) + + +class TestAgentSettingsService: + """Tests for AgentSettingsService class.""" + + def test_get_agent_setting_template_endpoint(self, service, test_agent_type): + """Test get_agent_setting_template_endpoint returns correct endpoint.""" + endpoint = service.get_agent_setting_template_endpoint(test_agent_type) + assert "/agents/v1.0/settings/templates/" in endpoint + assert test_agent_type in endpoint + assert endpoint.startswith("https://") + + def test_get_agent_setting_template_endpoint_with_special_chars(self, service): + """Test endpoint encoding with special characters in agent type.""" + agent_type_with_special_chars = "agent/type with spaces" + endpoint = service.get_agent_setting_template_endpoint(agent_type_with_special_chars) + # URL encoding: spaces -> %20, / -> %2F + assert "agent%2Ftype%20with%20spaces" in endpoint + + def test_get_agent_settings_endpoint(self, service, test_agent_instance_id): + """Test get_agent_settings_endpoint returns correct endpoint.""" + endpoint = service.get_agent_settings_endpoint(test_agent_instance_id) + assert "/agents/v1.0/settings/instances/" in endpoint + assert test_agent_instance_id in endpoint + assert endpoint.startswith("https://") + + def test_get_agent_settings_endpoint_with_special_chars(self, service): + """Test endpoint encoding with special characters in agent instance id.""" + instance_id_with_special_chars = "instance/id with spaces" + endpoint = service.get_agent_settings_endpoint(instance_id_with_special_chars) + # URL encoding: spaces -> %20, / -> %2F + assert "instance%2Fid%20with%20spaces" in endpoint + + @pytest.mark.asyncio + async def test_get_agent_setting_template_success( + self, service, test_agent_type, test_access_token + ): + """Test successfully retrieving agent setting template.""" + mock_template_data = { + "agentType": test_agent_type, + "settings": {"setting1": "value1", "setting2": 42}, + "metadata": {"version": "1.0"}, + } + + mock_response = Mock(spec=httpx.Response) + mock_response.is_success = True + mock_response.status_code = 200 + mock_response.json.return_value = mock_template_data + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.get.return_value = mock_response + mock_client_class.return_value = mock_client + + result = await service.get_agent_setting_template(test_agent_type, test_access_token) + + assert result.agent_type == test_agent_type + assert result.settings == {"setting1": "value1", "setting2": 42} + assert result.metadata == {"version": "1.0"} + + mock_client.get.assert_called_once() + call_args = mock_client.get.call_args + assert f"/settings/templates/{test_agent_type}" in call_args[0][0] + assert call_args[1]["headers"]["Authorization"] == f"Bearer {test_access_token}" + + @pytest.mark.asyncio + async def test_get_agent_setting_template_failure( + self, service, test_agent_type, test_access_token + ): + """Test error handling when API returns non-ok status.""" + mock_response = Mock(spec=httpx.Response) + mock_response.is_success = False + mock_response.status_code = 404 + mock_response.reason_phrase = "Not Found" + mock_response.request = Mock() + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.get.return_value = mock_response + mock_client_class.return_value = mock_client + + with pytest.raises(httpx.HTTPStatusError, match="Failed to get agent setting template"): + await service.get_agent_setting_template(test_agent_type, test_access_token) + + @pytest.mark.asyncio + async def test_set_agent_setting_template_success( + self, service, test_agent_type, test_access_token + ): + """Test successfully setting agent setting template.""" + template = AgentSettingTemplate( + agent_type=test_agent_type, + settings={"setting1": "value1", "setting2": 42}, + metadata={"version": "1.0"}, + ) + + mock_response_data = { + "agentType": test_agent_type, + "settings": {"setting1": "value1", "setting2": 42}, + "metadata": {"version": "1.1"}, + } + + mock_response = Mock(spec=httpx.Response) + mock_response.is_success = True + mock_response.status_code = 200 + mock_response.json.return_value = mock_response_data + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.put.return_value = mock_response + mock_client_class.return_value = mock_client + + result = await service.set_agent_setting_template(template, test_access_token) + + assert result.agent_type == test_agent_type + assert result.settings == {"setting1": "value1", "setting2": 42} + assert result.metadata == {"version": "1.1"} + + mock_client.put.assert_called_once() + call_args = mock_client.put.call_args + assert f"/settings/templates/{test_agent_type}" in call_args[0][0] + assert call_args[1]["headers"]["Authorization"] == f"Bearer {test_access_token}" + assert call_args[1]["json"]["agentType"] == test_agent_type + + @pytest.mark.asyncio + async def test_set_agent_setting_template_failure( + self, service, test_agent_type, test_access_token + ): + """Test error handling when setting template fails.""" + template = AgentSettingTemplate( + agent_type=test_agent_type, + settings={"setting1": "value1"}, + ) + + mock_response = Mock(spec=httpx.Response) + mock_response.is_success = False + mock_response.status_code = 400 + mock_response.reason_phrase = "Bad Request" + mock_response.request = Mock() + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.put.return_value = mock_response + mock_client_class.return_value = mock_client + + with pytest.raises(httpx.HTTPStatusError, match="Failed to set agent setting template"): + await service.set_agent_setting_template(template, test_access_token) + + @pytest.mark.asyncio + async def test_get_agent_settings_success( + self, service, test_agent_instance_id, test_agent_type, test_access_token + ): + """Test successfully retrieving agent settings.""" + mock_settings_data = { + "agentInstanceId": test_agent_instance_id, + "agentType": test_agent_type, + "settings": {"instanceSetting1": "value1", "instanceSetting2": 100}, + "metadata": {"lastUpdated": "2024-01-01T00:00:00Z"}, + } + + mock_response = Mock(spec=httpx.Response) + mock_response.is_success = True + mock_response.status_code = 200 + mock_response.json.return_value = mock_settings_data + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.get.return_value = mock_response + mock_client_class.return_value = mock_client + + result = await service.get_agent_settings(test_agent_instance_id, test_access_token) + + assert result.agent_instance_id == test_agent_instance_id + assert result.agent_type == test_agent_type + assert result.settings == {"instanceSetting1": "value1", "instanceSetting2": 100} + assert result.metadata == {"lastUpdated": "2024-01-01T00:00:00Z"} + + mock_client.get.assert_called_once() + call_args = mock_client.get.call_args + assert f"/settings/instances/{test_agent_instance_id}" in call_args[0][0] + assert call_args[1]["headers"]["Authorization"] == f"Bearer {test_access_token}" + + @pytest.mark.asyncio + async def test_get_agent_settings_failure( + self, service, test_agent_instance_id, test_access_token + ): + """Test error handling when getting agent settings fails.""" + mock_response = Mock(spec=httpx.Response) + mock_response.is_success = False + mock_response.status_code = 403 + mock_response.reason_phrase = "Forbidden" + mock_response.request = Mock() + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.get.return_value = mock_response + mock_client_class.return_value = mock_client + + with pytest.raises(httpx.HTTPStatusError, match="Failed to get agent settings"): + await service.get_agent_settings(test_agent_instance_id, test_access_token) + + @pytest.mark.asyncio + async def test_set_agent_settings_success( + self, service, test_agent_instance_id, test_agent_type, test_access_token + ): + """Test successfully setting agent settings.""" + settings = AgentSettings( + agent_instance_id=test_agent_instance_id, + agent_type=test_agent_type, + settings={"instanceSetting1": "value1", "instanceSetting2": 100}, + metadata={"lastUpdated": "2024-01-01T00:00:00Z"}, + ) + + mock_response_data = { + "agentInstanceId": test_agent_instance_id, + "agentType": test_agent_type, + "settings": {"instanceSetting1": "value1", "instanceSetting2": 100, "newSetting": True}, + "metadata": {"lastUpdated": "2024-01-02T00:00:00Z"}, + } + + mock_response = Mock(spec=httpx.Response) + mock_response.is_success = True + mock_response.status_code = 200 + mock_response.json.return_value = mock_response_data + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.put.return_value = mock_response + mock_client_class.return_value = mock_client + + result = await service.set_agent_settings(settings, test_access_token) + + assert result.agent_instance_id == test_agent_instance_id + assert result.agent_type == test_agent_type + assert result.settings["newSetting"] is True + + mock_client.put.assert_called_once() + call_args = mock_client.put.call_args + assert f"/settings/instances/{test_agent_instance_id}" in call_args[0][0] + assert call_args[1]["headers"]["Authorization"] == f"Bearer {test_access_token}" + assert call_args[1]["json"]["agentInstanceId"] == test_agent_instance_id + + @pytest.mark.asyncio + async def test_set_agent_settings_failure( + self, service, test_agent_instance_id, test_agent_type, test_access_token + ): + """Test error handling when setting agent settings fails.""" + settings = AgentSettings( + agent_instance_id=test_agent_instance_id, + agent_type=test_agent_type, + settings={"instanceSetting1": "value1"}, + ) + + mock_response = Mock(spec=httpx.Response) + mock_response.is_success = False + mock_response.status_code = 500 + mock_response.reason_phrase = "Internal Server Error" + mock_response.request = Mock() + + with patch("httpx.AsyncClient") as mock_client_class: + mock_client = AsyncMock() + mock_client.__aenter__.return_value = mock_client + mock_client.put.return_value = mock_response + mock_client_class.return_value = mock_client + + with pytest.raises( + httpx.HTTPStatusError, match="Failed to set agent settings for instance" + ): + await service.set_agent_settings(settings, test_access_token) + + @pytest.mark.parametrize( + "cluster,expected_domain", + [ + ("prod", "api.powerplatform.com"), + ("gov", "api.gov.powerplatform.microsoft.us"), + ("high", "api.high.powerplatform.microsoft.us"), + ], + ) + def test_different_cluster_categories( + self, test_tenant_id, test_agent_type, cluster, expected_domain + ): + """Test endpoint construction with different cluster categories.""" + discovery = PowerPlatformApiDiscovery(cluster) + test_service = AgentSettingsService(discovery, test_tenant_id) + + endpoint = test_service.get_agent_setting_template_endpoint(test_agent_type) + + assert expected_domain in endpoint + assert "/agents/v1.0/settings/templates/" in endpoint