From 72364f535a4c7ca022a61c9bcbcfa2dd1f241e3f Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Wed, 26 Feb 2025 13:39:30 -0800 Subject: [PATCH 1/4] mcs copilot package creation --- .../agents/copilotstudio/client/__init__.py | 0 .../agents/copilotstudio/client/bot_type.py | 5 +++++ .../client/power_platform_cloud.py | 0 .../pyproject.toml | 21 +++++++++++++++++++ 4 files changed, 26 insertions(+) create mode 100644 libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/__init__.py create mode 100644 libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py create mode 100644 libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_cloud.py create mode 100644 libraries/Client/microsoft-agents-copilotstudio-client/pyproject.toml diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/__init__.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py new file mode 100644 index 00000000..1f8aff39 --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py @@ -0,0 +1,5 @@ +from enum import Enum + +class BotType(str, Enum): + published = "published" + prebuilt = "prebuilt" diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_cloud.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_cloud.py new file mode 100644 index 00000000..e69de29b diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/pyproject.toml b/libraries/Client/microsoft-agents-copilotstudio-client/pyproject.toml new file mode 100644 index 00000000..64f848c5 --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "microsoft-agents-copilotstudio-client" +version = "0.0.0a1" +description = "A client library for Microsoft Agents" +authors = [{name = "Microsoft Corporation"}] +requires-python = ">=3.9" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +dependencies = [ + "microsoft-agents-core", +] + +[project.urls] +"Homepage" = "https://github.com/yourusername/microsoft-agents-client" From cfe760202cab318f7cc72c4af372c9eea0423ee2 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Wed, 26 Feb 2025 16:51:06 -0800 Subject: [PATCH 2/4] Configuration --- .../agents/copilotstudio/client/bot_type.py | 1 + .../client/connection_configuration.py | 1 + ...ngine_connection_configuration_protocol.py | 30 +++++++++++++++++++ .../client/power_platform_cloud.py | 25 ++++++++++++++++ 4 files changed, 57 insertions(+) create mode 100644 libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_configuration.py create mode 100644 libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_configuration_protocol.py diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py index 1f8aff39..5056ac1b 100644 --- a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py @@ -1,5 +1,6 @@ from enum import Enum + class BotType(str, Enum): published = "published" prebuilt = "prebuilt" diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_configuration.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_configuration.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_configuration.py @@ -0,0 +1 @@ + diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_configuration_protocol.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_configuration_protocol.py new file mode 100644 index 00000000..ef3e3759 --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_configuration_protocol.py @@ -0,0 +1,30 @@ +from typing import Protocol, Optional + +from .bot_type import BotType +from .power_platform_cloud import PowerPlatformCloud + + +class DirectToEngineConnectionConfigurationProtocol(Protocol): + """ + Protocol for DirectToEngineConnectionConfiguration. + """ + + # Schema name for the Copilot Studio Hosted Copilot. + + BOT_IDENTIFIER: Optional[str] + + # if PowerPlatformCloud is set to Other, this is the url for the power platform API endpoint. + + CUSTOM_POWER_PLATFORM_CLOUD: Optional[str] + + # Environment ID for the environment that hosts the bot + + EnvironmentId: Optional[str] + + # Power Platform Cloud where the environment is hosted + + CLOUD: Optional[PowerPlatformCloud] + + # Type of Bot hosted in Copilot Studio + + COPILOT_BOT_TYPE: Optional[BotType] diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_cloud.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_cloud.py index e69de29b..e7324f86 100644 --- a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_cloud.py +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_cloud.py @@ -0,0 +1,25 @@ +from enum import Enum + + +class PowerPlatformCloud(str, Enum): + """ + Enum representing different Power Platform Clouds. + """ + + Unknown = "Unknown" + Exp = "Exp" + Dev = "Dev" + Test = "Test" + Preprod = "Preprod" + FirstRelease = "FirstRelease" + Prod = "Prod" + Gov = "Gov" + High = "High" + DoD = "DoD" + Mooncake = "Mooncake" + Ex = "Ex" + Rx = "Rx" + Prv = "Prv" + Local = "Local" + GovFR = "GovFR" + Other = "Other" From c95113ae036ba611c8b5b67fce1e917ace0c1ea5 Mon Sep 17 00:00:00 2001 From: Axel Suarez Martinez Date: Wed, 26 Feb 2025 20:39:17 -0800 Subject: [PATCH 3/4] CopilotClient WIP --- .../agents/copilotstudio/client/bot_type.py | 4 +- .../client/connection_configuration.py | 1 - .../client/connection_settings.py | 20 ++++++ .../copilotstudio/client/copilot_client.py | 71 +++++++++++++++++++ ...to_engine_connection_settings_protocol.py} | 9 +-- .../client/execute_turn_request.py | 6 ++ .../client/power_platform_cloud.py | 34 ++++----- 7 files changed, 118 insertions(+), 27 deletions(-) delete mode 100644 libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_configuration.py create mode 100644 libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_settings.py create mode 100644 libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/copilot_client.py rename libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/{direct_to_engine_connection_configuration_protocol.py => direct_to_engine_connection_settings_protocol.py} (87%) create mode 100644 libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/execute_turn_request.py diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py index 5056ac1b..cd6dc138 100644 --- a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py @@ -2,5 +2,5 @@ class BotType(str, Enum): - published = "published" - prebuilt = "prebuilt" + PUBLISHED = "published" + PREBUILT = "prebuilt" diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_configuration.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_configuration.py deleted file mode 100644 index 8b137891..00000000 --- a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_configuration.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_settings.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_settings.py new file mode 100644 index 00000000..6063ceac --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_settings.py @@ -0,0 +1,20 @@ +from .direct_to_engine_connection_settings_protocol import ( + DirectToEngineConnectionSettingsProtocol, +) +from .power_platform_cloud import PowerPlatformCloud +from .bot_type import BotType + + +# TODO: Should rename to MCSConnectionSettings? +class ConnectionSettings(DirectToEngineConnectionSettingsProtocol): + """ + Connection settings for the DirectToEngineConnectionConfiguration. + """ + + def __init__(self, config: dict): + if config: + self.ENVIRONMENT_ID = config["ENVIRONMENT_ID"] + self.BOT_IDENTIFIER = config["BOT_IDENTIFIER"] + self.CLOUD = config.get("CLOUD", PowerPlatformCloud.UNKNOWN) + self.COPILOT_BOT_TYPE = config.get("COPILOT_BOT_TYPE", BotType.PUBLISHED) + self.CUSTOM_POWER_PLATFORM_CLOUD = config.get("CUSTOM_POWER_PLATFORM_CLOUD") diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/copilot_client.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/copilot_client.py new file mode 100644 index 00000000..d5a351a0 --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/copilot_client.py @@ -0,0 +1,71 @@ +import aiohttp +from typing import AsyncIterable, Callable, Optional + +from microsoft.agents.core.models import Activity, ConversationAccount + +from .connection_settings import ConnectionSettings +from .execute_turn_request import ExecuteTurnRequest + + +class CopilotClient: + def __init__( + self, + settings: ConnectionSettings, + logger: Callable, + token_provider_function: Optional[Callable[[str], str]] = None, + ): + self.settings = settings + self.logger = logger + self.token_provider_function = token_provider_function + self.conversation_id = "" + + async def post_request( + self, url: str, data: dict, headers: dict + ) -> AsyncIterable[Activity]: + async with aiohttp.ClientSession() as session: + async with session.post(url, json=data, headers=headers) as response: + if response.status != 200: + self.logger(f"Error sending request: {response.status}") + raise aiohttp.ClientError( + f"Error sending request: {response.status}" + ) + event_type = None + async for line in response.content: + if line.startswith(b"event:"): + event_type = line[6:].decode("utf-8").strip() + if line.startswith(b"data:") and event_type == "activity": + activity_data = line[5:].decode("utf-8").strip() + activity = Activity.from_json(activity_data) + yield activity + + async def start_conversation( + self, emit_start_conversation_event: bool = True + ) -> AsyncIterable[Activity]: + url = self.settings.get_connection_url() + data = {"EmitStartConversationEvent": emit_start_conversation_event} + headers = {"Content-Type": "application/json"} + if self.token_provider_function: + token = await self.token_provider_function(url) + headers["Authorization"] = f"Bearer {token}" + return self.post_request(url, data, headers) + + async def ask_question( + self, question: str, conversation_id: Optional[str] = None + ) -> AsyncIterable[Activity]: + activity = Activity( + type="message", + text=question, + conversation=ConversationAccount(id=conversation_id), + ) + return self.ask_question_with_activity(activity) + + async def ask_question_with_activity( + self, activity: Activity + ) -> AsyncIterable[Activity]: + url = self.settings.get_connection_url(self.conversation_id) + data = ExecuteTurnRequest(activity=activity).to_json() + headers = {"Content-Type": "application/json"} + if self.token_provider_function: + token = await self.token_provider_function(url) + headers["Authorization"] = f"Bearer {token}" + return self.post_request(url, data, headers) diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_configuration_protocol.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_settings_protocol.py similarity index 87% rename from libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_configuration_protocol.py rename to libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_settings_protocol.py index ef3e3759..e4c62a64 100644 --- a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_configuration_protocol.py +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_settings_protocol.py @@ -4,27 +4,22 @@ from .power_platform_cloud import PowerPlatformCloud -class DirectToEngineConnectionConfigurationProtocol(Protocol): +class DirectToEngineConnectionSettingsProtocol(Protocol): """ Protocol for DirectToEngineConnectionConfiguration. """ # Schema name for the Copilot Studio Hosted Copilot. - BOT_IDENTIFIER: Optional[str] # if PowerPlatformCloud is set to Other, this is the url for the power platform API endpoint. - CUSTOM_POWER_PLATFORM_CLOUD: Optional[str] # Environment ID for the environment that hosts the bot - - EnvironmentId: Optional[str] + ENVIRONMENT_ID: Optional[str] # Power Platform Cloud where the environment is hosted - CLOUD: Optional[PowerPlatformCloud] # Type of Bot hosted in Copilot Studio - COPILOT_BOT_TYPE: Optional[BotType] diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/execute_turn_request.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/execute_turn_request.py new file mode 100644 index 00000000..981e3c06 --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/execute_turn_request.py @@ -0,0 +1,6 @@ +from microsoft.agents.core.models import AgentsModel, Activity + + +class ExecuteTurnRequest(AgentsModel): + + activity: Activity diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_cloud.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_cloud.py index e7324f86..87d75e9a 100644 --- a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_cloud.py +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_cloud.py @@ -6,20 +6,20 @@ class PowerPlatformCloud(str, Enum): Enum representing different Power Platform Clouds. """ - Unknown = "Unknown" - Exp = "Exp" - Dev = "Dev" - Test = "Test" - Preprod = "Preprod" - FirstRelease = "FirstRelease" - Prod = "Prod" - Gov = "Gov" - High = "High" - DoD = "DoD" - Mooncake = "Mooncake" - Ex = "Ex" - Rx = "Rx" - Prv = "Prv" - Local = "Local" - GovFR = "GovFR" - Other = "Other" + UNKNOWN = "Unknown" + EXP = "Exp" + DEV = "Dev" + TEST = "Test" + PREPROD = "Preprod" + FIRST_RELEASE = "FirstRelease" + PROD = "Prod" + GOV = "Gov" + HIGH = "High" + DOD = "DoD" + MOONCAKE = "Mooncake" + EX = "Ex" + RX = "Rx" + PRV = "Prv" + LOCAL = "Local" + GOV_FR = "GovFR" + OTHER = "Other" From 4a46aa9a0c8fe5cbfabf5c42a02dd6048f522356 Mon Sep 17 00:00:00 2001 From: Axel Suarez Date: Fri, 28 Feb 2025 17:32:08 -0800 Subject: [PATCH 4/4] Copilot Client working\ --- .gitignore | 3 + .../agents/copilotstudio/client/__init__.py | 19 +++ .../client/connection_settings.py | 28 +++- .../copilotstudio/client/copilot_client.py | 81 ++++++--- ..._to_engine_connection_settings_protocol.py | 10 +- .../client/power_platform_environment.py | 156 ++++++++++++++++++ .../copilot_studio_client_sample/app.py | 62 +++++++ .../chat_console_service.py | 46 ++++++ .../copilot_studio_client_sample/config.py | 46 ++++++ .../msal_cache_plugin.py | 30 ++++ 10 files changed, 445 insertions(+), 36 deletions(-) create mode 100644 libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_environment.py create mode 100644 test_samples/copilot_studio_client_sample/app.py create mode 100644 test_samples/copilot_studio_client_sample/chat_console_service.py create mode 100644 test_samples/copilot_studio_client_sample/config.py create mode 100644 test_samples/copilot_studio_client_sample/msal_cache_plugin.py diff --git a/.gitignore b/.gitignore index 0723fd02..900f432a 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,6 @@ cython_debug/ # vscode .vscode/ + +# Binary files +bin/ \ No newline at end of file diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/__init__.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/__init__.py index e69de29b..6b5e2107 100644 --- a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/__init__.py +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/__init__.py @@ -0,0 +1,19 @@ +from .bot_type import BotType +from .connection_settings import ConnectionSettings +from .copilot_client import CopilotClient +from .direct_to_engine_connection_settings_protocol import ( + DirectToEngineConnectionSettingsProtocol, +) +from .execute_turn_request import ExecuteTurnRequest +from .power_platform_cloud import PowerPlatformCloud +from .power_platform_environment import PowerPlatformEnvironment + +__all__ = [ + "BotType", + "ConnectionSettings", + "CopilotClient", + "DirectToEngineConnectionSettingsProtocol", + "ExecuteTurnRequest", + "PowerPlatformCloud", + "PowerPlatformEnvironment", +] diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_settings.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_settings.py index 6063ceac..82bc570a 100644 --- a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_settings.py +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_settings.py @@ -1,3 +1,4 @@ +from typing import Optional from .direct_to_engine_connection_settings_protocol import ( DirectToEngineConnectionSettingsProtocol, ) @@ -5,16 +6,27 @@ from .bot_type import BotType -# TODO: Should rename to MCSConnectionSettings? class ConnectionSettings(DirectToEngineConnectionSettingsProtocol): """ Connection settings for the DirectToEngineConnectionConfiguration. """ - def __init__(self, config: dict): - if config: - self.ENVIRONMENT_ID = config["ENVIRONMENT_ID"] - self.BOT_IDENTIFIER = config["BOT_IDENTIFIER"] - self.CLOUD = config.get("CLOUD", PowerPlatformCloud.UNKNOWN) - self.COPILOT_BOT_TYPE = config.get("COPILOT_BOT_TYPE", BotType.PUBLISHED) - self.CUSTOM_POWER_PLATFORM_CLOUD = config.get("CUSTOM_POWER_PLATFORM_CLOUD") + def __init__( + self, + environment_id: str, + bot_identifier: str, + cloud: Optional[PowerPlatformCloud], + copilot_bot_type: Optional[BotType], + custom_power_platform_cloud: Optional[str], + ) -> None: + self.environment_id = environment_id + self.bot_identifier = bot_identifier + + if not self.environment_id: + raise ValueError("Environment ID must be provided") + if not self.bot_identifier: + raise ValueError("Bot Identifier must be provided") + + self.cloud = cloud or PowerPlatformCloud.UNKNOWN + self.copilot_bot_type = copilot_bot_type or BotType.PUBLISHED + self.custom_power_platform_cloud = custom_power_platform_cloud diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/copilot_client.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/copilot_client.py index d5a351a0..392af90d 100644 --- a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/copilot_client.py +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/copilot_client.py @@ -1,22 +1,28 @@ import aiohttp from typing import AsyncIterable, Callable, Optional -from microsoft.agents.core.models import Activity, ConversationAccount +from microsoft.agents.core.models import Activity, ActivityTypes, ConversationAccount from .connection_settings import ConnectionSettings from .execute_turn_request import ExecuteTurnRequest +from .power_platform_environment import PowerPlatformEnvironment class CopilotClient: + EVENT_STREAM_TYPE = "text/event-stream" + APPLICATION_JSON_TYPE = "application/json" + + _current_conversation_id = "" + def __init__( self, settings: ConnectionSettings, - logger: Callable, - token_provider_function: Optional[Callable[[str], str]] = None, + token: str, ): self.settings = settings - self.logger = logger - self.token_provider_function = token_provider_function + self._token = token + # TODO: Add logger + # self.logger = logger self.conversation_id = "" async def post_request( @@ -25,7 +31,7 @@ async def post_request( async with aiohttp.ClientSession() as session: async with session.post(url, json=data, headers=headers) as response: if response.status != 200: - self.logger(f"Error sending request: {response.status}") + # self.logger(f"Error sending request: {response.status}") raise aiohttp.ClientError( f"Error sending request: {response.status}" ) @@ -35,19 +41,28 @@ async def post_request( event_type = line[6:].decode("utf-8").strip() if line.startswith(b"data:") and event_type == "activity": activity_data = line[5:].decode("utf-8").strip() - activity = Activity.from_json(activity_data) + activity = Activity.model_validate_json(activity_data) + + if activity.type == ActivityTypes.message: + self._current_conversation_id = activity.conversation.id + yield activity async def start_conversation( self, emit_start_conversation_event: bool = True ) -> AsyncIterable[Activity]: - url = self.settings.get_connection_url() - data = {"EmitStartConversationEvent": emit_start_conversation_event} - headers = {"Content-Type": "application/json"} - if self.token_provider_function: - token = await self.token_provider_function(url) - headers["Authorization"] = f"Bearer {token}" - return self.post_request(url, data, headers) + url = PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings=self.settings + ) + data = {"emitStartConversationEvent": emit_start_conversation_event} + headers = { + "Content-Type": self.APPLICATION_JSON_TYPE, + "Authorization": f"Bearer {self._token}", + "Accept": self.EVENT_STREAM_TYPE, + } + + async for activity in self.post_request(url, data, headers): + yield activity async def ask_question( self, question: str, conversation_id: Optional[str] = None @@ -55,17 +70,37 @@ async def ask_question( activity = Activity( type="message", text=question, - conversation=ConversationAccount(id=conversation_id), + conversation=ConversationAccount( + id=conversation_id or self._current_conversation_id + ), ) - return self.ask_question_with_activity(activity) + + async for activity in self.ask_question_with_activity(activity): + yield activity async def ask_question_with_activity( self, activity: Activity ) -> AsyncIterable[Activity]: - url = self.settings.get_connection_url(self.conversation_id) - data = ExecuteTurnRequest(activity=activity).to_json() - headers = {"Content-Type": "application/json"} - if self.token_provider_function: - token = await self.token_provider_function(url) - headers["Authorization"] = f"Bearer {token}" - return self.post_request(url, data, headers) + if not activity: + raise ValueError( + "CopilotClient.ask_question_with_activity: Activity cannot be None" + ) + + local_conversation_id = ( + activity.conversation.id or self._current_conversation_id + ) + + url = PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings=self.settings, conversation_id=local_conversation_id + ) + data = ExecuteTurnRequest(activity=activity).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + headers = { + "Content-Type": self.APPLICATION_JSON_TYPE, + "Authorization": f"Bearer {self._token}", + "Accept": self.EVENT_STREAM_TYPE, + } + + async for activity in self.post_request(url, data, headers): + yield activity diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_settings_protocol.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_settings_protocol.py index e4c62a64..38eecffd 100644 --- a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_settings_protocol.py +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_settings_protocol.py @@ -10,16 +10,16 @@ class DirectToEngineConnectionSettingsProtocol(Protocol): """ # Schema name for the Copilot Studio Hosted Copilot. - BOT_IDENTIFIER: Optional[str] + bot_identifier: Optional[str] # if PowerPlatformCloud is set to Other, this is the url for the power platform API endpoint. - CUSTOM_POWER_PLATFORM_CLOUD: Optional[str] + custom_power_platform_cloud: Optional[str] # Environment ID for the environment that hosts the bot - ENVIRONMENT_ID: Optional[str] + environment_id: Optional[str] # Power Platform Cloud where the environment is hosted - CLOUD: Optional[PowerPlatformCloud] + cloud: Optional[PowerPlatformCloud] # Type of Bot hosted in Copilot Studio - COPILOT_BOT_TYPE: Optional[BotType] + copilot_bot_type: Optional[BotType] diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_environment.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_environment.py new file mode 100644 index 00000000..a0228032 --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_environment.py @@ -0,0 +1,156 @@ +from urllib.parse import urlparse, urlunparse +from typing import Optional +from .connection_settings import ConnectionSettings +from .bot_type import BotType +from .power_platform_cloud import PowerPlatformCloud + + +# TODO: POC provides the +class PowerPlatformEnvironment: + """ + Class representing the Power Platform Environment. + """ + + API_VERSION = "2022-03-01-preview" + + @staticmethod + def get_copilot_studio_connection_url( + settings: ConnectionSettings, + conversation_id: Optional[str] = None, + bot_type: BotType = BotType.PUBLISHED, + cloud: PowerPlatformCloud = PowerPlatformCloud.PROD, + cloud_base_address: Optional[str] = None, + ) -> str: + if cloud == PowerPlatformCloud.OTHER and not cloud_base_address: + raise ValueError( + "cloud_base_address must be provided when PowerPlatformCloud is Other" + ) + if not settings.environment_id: + raise ValueError("EnvironmentId must be provided") + if not settings.bot_identifier: + raise ValueError("BotIdentifier must be provided") + if settings.cloud and settings.cloud != PowerPlatformCloud.UNKNOWN: + cloud = settings.cloud + if cloud == PowerPlatformCloud.OTHER: + parsed_url = urlparse(cloud_base_address) + is_absolute_url = parsed_url.scheme and parsed_url.netloc + if cloud_base_address and is_absolute_url: + pass + elif settings.custom_power_platform_cloud: + cloud_base_address = settings.custom_power_platform_cloud + else: + raise ValueError( + "Either CustomPowerPlatformCloud or cloud_base_address must be provided when PowerPlatformCloud is Other" + ) + if settings.copilot_bot_type: + bot_type = settings.copilot_bot_type + + cloud_base_address = cloud_base_address or "api.unknown.powerplatform.com" + host = PowerPlatformEnvironment.get_environment_endpoint( + cloud, settings.environment_id, cloud_base_address + ) + return PowerPlatformEnvironment.create_uri( + settings.bot_identifier, host, bot_type, conversation_id + ) + + @staticmethod + def get_token_audience( + settings: Optional[ConnectionSettings] = None, + cloud: PowerPlatformCloud = PowerPlatformCloud.UNKNOWN, + cloud_base_address: Optional[str] = None, + ) -> str: + if cloud == PowerPlatformCloud.OTHER and not cloud_base_address: + raise ValueError( + "cloud_base_address must be provided when PowerPlatformCloud is Other" + ) + if not settings and cloud == PowerPlatformCloud.UNKNOWN: + raise ValueError("Either settings or cloud must be provided") + if settings and settings.cloud and settings.cloud != PowerPlatformCloud.UNKNOWN: + cloud = settings.cloud + if cloud == PowerPlatformCloud.OTHER: + if cloud_base_address and urlparse(cloud_base_address).scheme: + cloud = PowerPlatformCloud.OTHER + elif ( + settings + and settings.custom_power_platform_cloud + and urlparse(settings.custom_power_platform_cloud).scheme + ): + cloud = PowerPlatformCloud.OTHER + cloud_base_address = settings.custom_power_platform_cloud + else: + raise ValueError( + "Either CustomPowerPlatformCloud or cloud_base_address must be provided when PowerPlatformCloud is Other" + ) + + cloud_base_address = cloud_base_address or "api.unknown.powerplatform.com" + return f"https://{PowerPlatformEnvironment.get_endpoint_suffix(cloud, cloud_base_address)}/.default" + + @staticmethod + def create_uri( + bot_identifier: str, + host: str, + bot_type: BotType, + conversation_id: Optional[str], + ) -> str: + bot_path_name = ( + "dataverse-backed" if bot_type == BotType.PUBLISHED else "prebuilt" + ) + path = f"/copilotstudio/{bot_path_name}/authenticated/bots/{bot_identifier}/conversations" + if conversation_id: + path += f"/{conversation_id}" + return urlunparse( + ( + "https", + host, + path, + "", + f"api-version={PowerPlatformEnvironment.API_VERSION}", + "", + ) + ) + + @staticmethod + def get_environment_endpoint( + cloud: PowerPlatformCloud, + environment_id: str, + cloud_base_address: Optional[str] = None, + ) -> str: + if cloud == PowerPlatformCloud.OTHER and not cloud_base_address: + raise ValueError( + "cloud_base_address must be provided when PowerPlatformCloud is Other" + ) + cloud_base_address = cloud_base_address or "api.unknown.powerplatform.com" + normalized_resource_id = environment_id.lower().replace("-", "") + id_suffix_length = PowerPlatformEnvironment.get_id_suffix_length(cloud) + hex_prefix = normalized_resource_id[:-id_suffix_length] + hex_suffix = normalized_resource_id[-id_suffix_length:] + return f"{hex_prefix}.{hex_suffix}.environment.{PowerPlatformEnvironment.get_endpoint_suffix(cloud, cloud_base_address)}" + + @staticmethod + def get_endpoint_suffix(cloud: PowerPlatformCloud, cloud_base_address: str) -> str: + return { + PowerPlatformCloud.LOCAL: "api.powerplatform.localhost", + PowerPlatformCloud.EXP: "api.exp.powerplatform.com", + PowerPlatformCloud.DEV: "api.dev.powerplatform.com", + PowerPlatformCloud.PRV: "api.prv.powerplatform.com", + PowerPlatformCloud.TEST: "api.test.powerplatform.com", + PowerPlatformCloud.PREPROD: "api.preprod.powerplatform.com", + PowerPlatformCloud.FIRST_RELEASE: "api.powerplatform.com", + PowerPlatformCloud.PROD: "api.powerplatform.com", + PowerPlatformCloud.GOV_FR: "api.gov.powerplatform.microsoft.us", + PowerPlatformCloud.GOV: "api.gov.powerplatform.microsoft.us", + PowerPlatformCloud.HIGH: "api.high.powerplatform.microsoft.us", + PowerPlatformCloud.DOD: "api.appsplatform.us", + PowerPlatformCloud.MOONCAKE: "api.powerplatform.partner.microsoftonline.cn", + PowerPlatformCloud.EX: "api.powerplatform.eaglex.ic.gov", + PowerPlatformCloud.RX: "api.powerplatform.microsoft.scloud", + PowerPlatformCloud.OTHER: cloud_base_address, + }.get(cloud, ValueError(f"Invalid cloud category value: {cloud}")) + + @staticmethod + def get_id_suffix_length(cloud: PowerPlatformCloud) -> int: + return ( + 2 + if cloud in {PowerPlatformCloud.FIRST_RELEASE, PowerPlatformCloud.PROD} + else 1 + ) diff --git a/test_samples/copilot_studio_client_sample/app.py b/test_samples/copilot_studio_client_sample/app.py new file mode 100644 index 00000000..bfd9b750 --- /dev/null +++ b/test_samples/copilot_studio_client_sample/app.py @@ -0,0 +1,62 @@ +import asyncio +from dotenv import load_dotenv +from os import environ, path +from msal import PublicClientApplication + +from microsoft.agents.copilotstudio.client import ConnectionSettings, CopilotClient + +from msal_cache_plugin import get_msal_token_cache +from chat_console_service import ChatConsoleService +from config import McsConnectionSettings + +load_dotenv() +connection_settings = McsConnectionSettings() + + +def aquire_token(mcs_settings: McsConnectionSettings, cache_path: str) -> str: + cache = get_msal_token_cache(cache_path) + app = PublicClientApplication( + mcs_settings.app_client_id, + authority=f"https://login.microsoftonline.com/{mcs_settings.tenant_id}", + token_cache=cache, + ) + + token_scopes = ["https://api.powerplatform.com/.default"] + + accounts = app.get_accounts() + + if accounts: + # If so, you could then somehow display these accounts and let end user choose + chosen = accounts[0] + result = app.acquire_token_silent(scopes=token_scopes, account=chosen) + else: + # At this point, you can save you can update your cache if you are using token caching + # check result variable, if its None then you should interactively acquire a token + # So no suitable token exists in cache. Let's get a new one from Microsoft Entra. + result = app.acquire_token_interactive(scopes=token_scopes) + + if "access_token" in result: + return result["access_token"] + else: + print(result.get("error")) + print(result.get("error_description")) + print(result.get("correlation_id")) # You may need this when reporting a bug + raise Exception("Authentication with the Public Application failed") + + +def create_mcs_client(connection_settings: ConnectionSettings) -> CopilotClient: + token = aquire_token( + connection_settings, + environ.get("TOKEN_CACHE_PATH") + or path.join(path.dirname(__file__), "bin/token_cache.bin"), + ) + return CopilotClient(connection_settings, token) + + +loop = asyncio.get_event_loop() +try: + loop.run_until_complete( + ChatConsoleService(create_mcs_client(connection_settings)).start_service() + ) +finally: + loop.close() diff --git a/test_samples/copilot_studio_client_sample/chat_console_service.py b/test_samples/copilot_studio_client_sample/chat_console_service.py new file mode 100644 index 00000000..ab749f8a --- /dev/null +++ b/test_samples/copilot_studio_client_sample/chat_console_service.py @@ -0,0 +1,46 @@ +from microsoft.agents.copilotstudio.client import CopilotClient +from microsoft.agents.core.models import Activity, ActivityTypes + + +class ChatConsoleService: + + def __init__(self, copilot_client: CopilotClient): + self._copilot_client = copilot_client + + async def start_service(self): + print("bot> ") + + # Attempt to connect to the copilot studio hosted bot here + # if successful, this will loop though all events that the Copilot Studio bot sends to the client setup the conversation. + async for activity in self._copilot_client.start_conversation(): + if not activity: + raise Exception("ChatConsoleService.start_service: Activity is None") + + self._print_activity(activity) + + # Once we are connected and have initiated the conversation, begin the message loop with the Console. + while True: + question = input("user> ") + + # Send the user input to the Copilot Studio bot and await the response. + # In this case we are not sending a conversation ID, as the bot is already connected by "StartConversationAsync", a conversation ID is persisted by the underlying client. + async for bot_activity in self._copilot_client.ask_question(question): + self._print_activity(bot_activity) + + @staticmethod + def _print_activity(activity: Activity): + if activity.type == ActivityTypes.message: + if activity.text_format == "markdown": + print(activity.text) + if activity.suggested_actions and activity.suggested_actions.actions: + print("Suggested actions:") + for action in activity.suggested_actions.actions: + print(f" - {action.text}") + else: + print(activity.text) + elif activity.type == ActivityTypes.typing: + print(".") + elif activity.type == ActivityTypes.event: + print("+") + else: + print(f"Activity type: [{activity.type}]") diff --git a/test_samples/copilot_studio_client_sample/config.py b/test_samples/copilot_studio_client_sample/config.py new file mode 100644 index 00000000..73f4cca5 --- /dev/null +++ b/test_samples/copilot_studio_client_sample/config.py @@ -0,0 +1,46 @@ +from os import environ +from typing import Optional + +from microsoft.agents.copilotstudio.client import ( + ConnectionSettings, + PowerPlatformCloud, + BotType, +) + + +class McsConnectionSettings(ConnectionSettings): + def __init__( + self, + app_client_id: Optional[str] = None, + tenant_id: Optional[str] = None, + environment_id: Optional[str] = None, + bot_identifier: Optional[str] = None, + cloud: Optional[PowerPlatformCloud] = None, + copilot_bot_type: Optional[BotType] = None, + custom_power_platform_cloud: Optional[str] = None, + ) -> None: + self.app_client_id = app_client_id or environ.get("APP_CLIENT_ID") + self.tenant_id = tenant_id or environ.get("TENANT_ID") + + if not self.app_client_id: + raise ValueError("App Client ID must be provided") + if not self.tenant_id: + raise ValueError("Tenant ID must be provided") + + environment_id = environment_id or environ.get("ENVIRONMENT_ID") + bot_identifier = bot_identifier or environ.get("BOT_IDENTIFIER") + cloud = cloud or PowerPlatformCloud[environ.get("CLOUD", "UNKNOWN")] + copilot_bot_type = ( + copilot_bot_type or BotType[environ.get("COPILOT_BOT_TYPE", "PUBLISHED")] + ) + custom_power_platform_cloud = custom_power_platform_cloud or environ.get( + "CUSTOM_POWER_PLATFORM_CLOUD", None + ) + + super().__init__( + environment_id, + bot_identifier, + cloud, + copilot_bot_type, + custom_power_platform_cloud, + ) diff --git a/test_samples/copilot_studio_client_sample/msal_cache_plugin.py b/test_samples/copilot_studio_client_sample/msal_cache_plugin.py new file mode 100644 index 00000000..a3180a32 --- /dev/null +++ b/test_samples/copilot_studio_client_sample/msal_cache_plugin.py @@ -0,0 +1,30 @@ +import logging +import json + +from msal_extensions import ( + build_encrypted_persistence, + FilePersistence, + PersistedTokenCache, +) + + +def get_msal_token_cache( + cache_path: str, fallback_to_plaintext=True +) -> PersistedTokenCache: + + persistence = None + + # Note: This sample stores both encrypted persistence and plaintext persistence + # into same location, therefore their data would likely override with each other. + try: + persistence = build_encrypted_persistence(cache_path) + except: # pylint: disable=bare-except + # On Linux, encryption exception will be raised during initialization. + # On Windows and macOS, they won't be detected here, + # but will be raised during their load() or save(). + if not fallback_to_plaintext: + raise + logging.warning("Encryption unavailable. Opting in to plain text.") + persistence = FilePersistence(cache_path) + + return PersistedTokenCache(persistence)